From c85c2ea2ee0cdae08f3fca5344ead7f2912e33b4 Mon Sep 17 00:00:00 2001 From: Yoshiharu Uematsu Date: Wed, 9 Sep 2026 01:54:06 +0900 Subject: [PATCH 1/4] spec(update): detect a clone that cannot fetch branches on the exists path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit clone mode の `exists` 経路は `fetch --tags` が branch も進めることを前提に していたが、`remote.origin.fetch` が `refs/heads/` を 1 本も持たない clone でも tag だけは解決するため、bootstrap は毎セッション正常完走し branch の遅れだけが 無症状で蓄積していた(実測: 374 コミット遅れが約 5 ヶ月、発見は偶発)。 検出のみを足す。修理はしない(issue の 解(a))。 - `Li+update.md` clone mode `exists` 経路に step a を追加し、既存 a-e を b-f へ繰り下げ - on-session-start hook の 3 port に verify 軸 4 を実装。 `remote.origin.fetch` が source 側を `refs/heads/` 以下に持つ refspec を 1 本も持たない場合、既存の marker reason surface へ `clone-refspec-no-branch-mapping` を積む(`sentinel-tag(...)` と同じ経路)。 clone でないディレクトリ(api mode)と `git` 不在は無言 skip - `adapter/*/CLAUDE.md` `AGENTS.md` の「`unnecessary` が何を verify 済みと言うか」 の列挙と、`docs/C.-Update.md` / `docs/6.-Adapter.md` の写しを同一 PR で更新 - `tests/test_clone_refspec_branch_check.py` = 3 port の behavioural(tag のみ / refspec 皆無 / wildcard / single-branch / clone でない / 修理しないこと)と 5 surface の parity 現行 literal は壊れていない。この state は現行の `not exists` 経路からは生まれず、 仕様の穴は「作ること」ではなく「検出しないこと」の側にある。 #1911 Co-Authored-By: Claude Opus 5 --- Li+update.md | 25 ++- adapter/claude/CLAUDE.md | 2 +- adapter/claude/hooks/on-session-start.sh | 26 ++- adapter/codex/AGENTS.md | 2 +- adapter/codex/hooks/on-session-start.ps1 | 12 ++ adapter/codex/hooks/on-session-start.sh | 9 ++ docs/6.-Adapter.md | 4 +- docs/C.-Update.md | 15 +- tests/test_clone_refspec_branch_check.py | 196 +++++++++++++++++++++++ 9 files changed, 272 insertions(+), 19 deletions(-) create mode 100644 tests/test_clone_refspec_branch_check.py diff --git a/Li+update.md b/Li+update.md index 26bbe437..85ac55d6 100644 --- a/Li+update.md +++ b/Li+update.md @@ -1,7 +1,7 @@ # Li+ Update Adapter / configuration sync procedure for Li+. -Invoked when the adapter sentinel tag, Li+config schema, or workspace language contract drifts from the target state (the on-session-start.sh hook emits `LI_PLUS_UPDATE_STATUS=needed` in that case). Most sessions skip this file because the hook reports `LI_PLUS_UPDATE_STATUS=unnecessary`. +Invoked when the adapter sentinel tag, Li+config schema, or workspace language contract drifts from the target state, or when a clone-mode workspace holds a clone that cannot fetch branches (the on-session-start.sh hook emits `LI_PLUS_UPDATE_STATUS=needed` in each of those cases). Most sessions skip this file because the hook reports `LI_PLUS_UPDATE_STATUS=unnecessary`. Never output credentials to chat. Read Li+config.md first to resolve all settings before executing this file. Phases execute in order. Each phase declares its dependencies. @@ -128,17 +128,30 @@ clone mode: `git -C {workspace_root}/{repo_dir} checkout {target_tag}` Both are the literal to execute; add no flags to either. Proceed to step 3. - exists -> fetch --tags, then: - a. Resolve and report both values: current checked-out tag and target tag from LI_PLUS_CHANNEL. + a. Check that the clone can fetch branches: `git -C {workspace_root}/{repo_dir} config --get-all + remote.origin.fetch` must carry at least one refspec whose source side is under `refs/heads/`. + A clone carrying none of those still resolves tags, so the `fetch --tags` just run advances tags + and leaves every branch where it was, and a later bare `git fetch origin` succeeds as a no-op. + If none is present, name the finding to the user, and name what it costs: local branches never + advance, so a worktree or a build taken from a local branch is taken from a stale tree. + Detection only. Do not add the refspec, do not re-clone, do not abort — continue to step b either + way. The repair is the user's: it writes shared local git state, which no agent takes on its own. + The same check runs every session as verification axis 4 of the three on-session-start hook ports, + which is what puts it on the `LI_PLUS_UPDATE_STATUS` marker as the + `clone-refspec-no-branch-mapping` reason. While the clone stays in that state the marker reports + `needed` every session and this walkthrough runs again; that persistence is the escalation, not a + drift the walkthrough is expected to clear. + b. Resolve and report both values: current checked-out tag and target tag from LI_PLUS_CHANNEL. Name which of the two is newer: the target is not necessarily the newer one, since a channel can resolve to a tag behind the current one. - b. If same -> continue. - c. If different -> ask the user how to proceed before continuing to Phase 4. + c. If same -> continue. + d. If different -> ask the user how to proceed before continuing to Phase 4. Do not report bootstrap completion before this choice is resolved. Minimum choices: - update now to the target tag - stay on the current tag for this session - d. Checkout the target tag only if the user agrees. - e. If the user chooses to stay, continue on the current tag only after explicitly naming both tags. + e. Checkout the target tag only if the user agrees. + f. If the user chooses to stay, continue on the current tag only after explicitly naming both tags. 3. Source files are now available at the resolved tag. Phase 4 handles reading. ## Phase 4: Host Integration diff --git a/adapter/claude/CLAUDE.md b/adapter/claude/CLAUDE.md index 561814ca..71747d82 100644 --- a/adapter/claude/CLAUDE.md +++ b/adapter/claude/CLAUDE.md @@ -20,7 +20,7 @@ Concept framing (Sheepdog Engineering): Execute the following at startup (never output credentials to chat): 1. Inspect the `LI_PLUS_UPDATE_STATUS=` marker emitted by `on-session-start.sh` (delimited by `━━━ Li+ update status ━━━` banner) in the session-opening context. - - `LI_PLUS_UPDATE_STATUS=unnecessary` -> skip step 2 entirely (no Li+update.md re-execution). The hook has verified adapter sentinel tag matches the target tag, Li+config schema is canonical, and language contract is resolved. On-demand spot read of Li+config.md for value lookup (e.g. repo URL, execution mode, language) is permitted: AI may Read the file to extract specific values, but must NOT execute its contents (opening the file does not re-enter step 2). Li+update.md re-execution remains prohibited. + - `LI_PLUS_UPDATE_STATUS=unnecessary` -> skip step 2 entirely (no Li+update.md re-execution). The hook has verified adapter sentinel tag matches the target tag, Li+config schema is canonical, language contract is resolved, and the Li+ clone can fetch branches. On-demand spot read of Li+config.md for value lookup (e.g. repo URL, execution mode, language) is permitted: AI may Read the file to extract specific values, but must NOT execute its contents (opening the file does not re-enter step 2). Li+update.md re-execution remains prohibited. - `LI_PLUS_UPDATE_STATUS=needed` (or marker absent) -> proceed to step 2. - Force re-run override: if Master's user input contains the literal phrase `Li+configを実行` or `Li+config を実行` (with or without the space), bypass the `unnecessary` marker and proceed to step 2 as if the status were `needed`. 2. Read Li+config.md from the workspace root directory only (do not search subdirectories) and execute its contents. (Ask the user for confirmation if needed during execution) diff --git a/adapter/claude/hooks/on-session-start.sh b/adapter/claude/hooks/on-session-start.sh index 58d6e103..f5efa9c3 100755 --- a/adapter/claude/hooks/on-session-start.sh +++ b/adapter/claude/hooks/on-session-start.sh @@ -186,13 +186,14 @@ fi # 99% of sessions have no tag change, no schema change, and all config values # resolved — verification only, no actual changes. The walkthrough costs ~4% # context (10% with Li+config execution vs 6% without). The hook performs the -# three verifications and emits a single-line update status marker the AI +# four verifications and emits a single-line update status marker the AI # parses to decide whether to read Li+config + Li+update at all. # -# Three axes (ALL must pass for "unnecessary"): +# Four axes (ALL must pass for "unnecessary"): # 1. adapter sentinel tag in .claude/CLAUDE.md == current LI_PLUS_REPO target tag (per LI_PLUS_CHANNEL) # 2. Li+config.md schema canonical (no legacy keys present) # 3. LI_PLUS_BASE_LANGUAGE and LI_PLUS_PROJECT_LANGUAGE resolved (non-comment, non-empty) +# 4. clone mode: remote.origin.fetch maps at least one branch (detection only; the hook does not repair) # # Marker format (machine/AI-parseable, single line + optional reason): # LI_PLUS_UPDATE_STATUS=unnecessary -> AI skips Li+update walkthrough; Li+config spot read (Read for value lookup, no execute) is permitted @@ -267,6 +268,27 @@ if [ -z "$BASE_LANG" ] || [ -z "$PROJ_LANG" ]; then UPDATE_REASONS+=("language-contract-unresolved(base=${BASE_LANG:-unset},project=${PROJ_LANG:-unset})") fi +# --- axis 4: clone can fetch branches (#1911) --- +# A clone whose `remote.origin.fetch` carries no `refs/heads/` source still +# resolves tags, so the `fetch --tags` on Li+update.md's clone-mode `exists` +# path succeeds while every branch stays where it was, and a later bare +# `git fetch origin` is a silent no-op rather than an error. No other axis here +# reports that: axis 1 reads tags, which are exactly what such a clone does +# advance, so it keeps emitting "unnecessary" while the branch lag accumulates +# with no surface at all. +# Detection only. The repair writes shared local git state, which is `high` +# caution under `rules/evolution/memory-entry-format.md` Artifact deletion +# calibration and stays with the human; this axis names the state and stops. +# The marker therefore reports "needed" every session until the human repairs +# the clone -- that persistence is the escalation, not a defect in the axis. +# Silent skip when the directory is not a clone (api mode) or `git` is absent: +# neither state is evidence about a refspec. +if [ -e "$LIPLUS_DIR/.git" ] && command -v git >/dev/null 2>&1; then + if ! git -C "$LIPLUS_DIR" config --get-all remote.origin.fetch 2>/dev/null | grep -q 'refs/heads/'; then + UPDATE_REASONS+=("clone-refspec-no-branch-mapping") + fi +fi + # --- emit update status marker --- # Always emit first, before any cold-start material, so AI parses it before # deciding whether to read Li+config.md and Li+update.md. diff --git a/adapter/codex/AGENTS.md b/adapter/codex/AGENTS.md index ec467933..1f2d6f02 100644 --- a/adapter/codex/AGENTS.md +++ b/adapter/codex/AGENTS.md @@ -20,7 +20,7 @@ Concept framing (Sheepdog Engineering): Execute the following at startup (never output credentials to chat): 1. Inspect the `LI_PLUS_UPDATE_STATUS=` marker emitted by the `on-session-start` SessionStart hook (delimited by the `━━━ Li+ update status ━━━` banner) in the session-opening context. - - `LI_PLUS_UPDATE_STATUS=unnecessary` -> skip step 2 entirely. The hook has verified adapter sentinel tag matches the target tag, Li+config schema is canonical, and the language contract is resolved. On-demand spot read of Li+config.md for value lookup (repo URL, execution mode, language) is permitted: Read the file to extract values, but do NOT execute its contents. + - `LI_PLUS_UPDATE_STATUS=unnecessary` -> skip step 2 entirely. The hook has verified adapter sentinel tag matches the target tag, Li+config schema is canonical, the language contract is resolved, and the Li+ clone can fetch branches. On-demand spot read of Li+config.md for value lookup (repo URL, execution mode, language) is permitted: Read the file to extract values, but do NOT execute its contents. - `LI_PLUS_UPDATE_STATUS=needed` (or marker absent) -> proceed to step 2. - Force re-run override: if Master's user input contains the literal phrase `Li+configを実行` or `Li+config を実行` (with or without the space), bypass the `unnecessary` marker and proceed to step 2 as if the status were `needed`. - Marker-absent fallback: if the marker is missing (hook not trusted yet, or pre-bootstrap), treat as `needed`. The marker is absent whenever the SessionStart hook did not run — most commonly because the one-time GUI trust has not been granted (see Rules: hook trust below). diff --git a/adapter/codex/hooks/on-session-start.ps1 b/adapter/codex/hooks/on-session-start.ps1 index 244d4477..f449d933 100644 --- a/adapter/codex/hooks/on-session-start.ps1 +++ b/adapter/codex/hooks/on-session-start.ps1 @@ -232,6 +232,18 @@ if ($matcher -ceq 'startup') { $updateReasons += "language-contract-unresolved(base=$b,project=$p)" } + # --- axis 4: clone can fetch branches (#1911) --- + # Rationale is in the claude port this one mirrors. Detection only; silent + # skip when the directory is not a clone or `git` is absent. + if ((Test-Path -LiteralPath (Join-Path $liplusDir '.git')) -and (Get-Command git -ErrorAction SilentlyContinue)) { + $fetchRefspecs = git -C $liplusDir config --get-all remote.origin.fetch 2>$null + # -cmatch, not -match: git ref names are case-sensitive and the two bash + # ports use a case-sensitive grep. #1804 is the same split, on another value. + if (-not ($fetchRefspecs -cmatch 'refs/heads/')) { + $updateReasons += 'clone-refspec-no-branch-mapping' + } + } + # --- emit update status marker --- if ($updateReasons.Count -eq 0) { Emit '━━━ Li+ update status ━━━' diff --git a/adapter/codex/hooks/on-session-start.sh b/adapter/codex/hooks/on-session-start.sh index ee99eacc..c8f906d3 100644 --- a/adapter/codex/hooks/on-session-start.sh +++ b/adapter/codex/hooks/on-session-start.sh @@ -205,6 +205,15 @@ if [ "$MATCHER" = "startup" ]; then UPDATE_REASONS+=("language-contract-unresolved(base=${BASE_LANG:-unset},project=${PROJ_LANG:-unset})") fi + # axis 4: clone can fetch branches (#1911) + # Rationale is in the claude port this one mirrors. Detection only; silent + # skip when the directory is not a clone or `git` is absent. + if [ -e "$LIPLUS_DIR/.git" ] && command -v git >/dev/null 2>&1; then + if ! git -C "$LIPLUS_DIR" config --get-all remote.origin.fetch 2>/dev/null | grep -q 'refs/heads/'; then + UPDATE_REASONS+=("clone-refspec-no-branch-mapping") + fi + fi + if [ "${#UPDATE_REASONS[@]}" -eq 0 ]; then emit "━━━ Li+ update status ━━━" emit "LI_PLUS_UPDATE_STATUS=unnecessary tag=$TARGET_TAG channel=$LI_PLUS_CHANNEL_VAL" diff --git a/docs/6.-Adapter.md b/docs/6.-Adapter.md index efaff1c2..63b2b074 100644 --- a/docs/6.-Adapter.md +++ b/docs/6.-Adapter.md @@ -84,9 +84,9 @@ settings.json の hook command はプロジェクトディレクトリにスペ cold-start 素材収集より前に、hook は `register_section` を経由しない printf 直接出力で最大4つのマーカーを emit する(`Li+config: unrecognized value` は該当時のみ)。いずれも diff-only 比較(sha256 fingerprint 方式)の対象外である。ただし各マーカーが matcher(`startup`/`resume`/`clear`/`compact`/`fork`)に関わらず毎回再 emit されるのは、`liplus-language` リポジトリ解決済み(bootstrap 後)の通常セッションに限る(Codex 版の `Li+ update status` のみ startup 限定。各マーカーの項を参照)。bootstrap 前の経路は挙動が異なるため、下記「pre-bootstrap 早期終了」を参照。 -- **`━━━ Li+ update status ━━━`**:`LI_PLUS_UPDATE_STATUS=unnecessary tag= channel=` または `LI_PLUS_UPDATE_STATUS=needed reason=` を出力する。adapter sentinel tag / Li+config schema / workspace 言語契約の3軸 verify 結果であり、意味論(unnecessary/needed の分岐条件と reason 軸)の正本は [C. 更新同期手続き](C.-Update) を参照する。AI 側の消費契約(Li+update.md walkthrough を skip するか実行するか)は `adapter/claude/CLAUDE.md` の "Execute the following at startup" ブロックが担い、本書では再掲しない。 +- **`━━━ Li+ update status ━━━`**:`LI_PLUS_UPDATE_STATUS=unnecessary tag= channel=` または `LI_PLUS_UPDATE_STATUS=needed reason=` を出力する。adapter sentinel tag / Li+config schema / workspace 言語契約 / clone の branch fetch 可否の4軸 verify 結果であり、意味論(unnecessary/needed の分岐条件と reason 軸)の正本は [C. 更新同期手続き](C.-Update) を参照する。AI 側の消費契約(Li+update.md walkthrough を skip するか実行するか)は `adapter/claude/CLAUDE.md` の "Execute the following at startup" ブロックが担い、本書では再掲しない。 - **`━━━ Li+config: unrecognized value ━━━`**:`LI_PLUS_CHANNEL` の値が既知の 3 値(`latest` / `release` / `tag`)のいずれでもなく、かつ空でもない場合のみ出力する。キー名と実際の値を含む 1 行を出すだけであり、既定へ倒れる挙動そのものは変えない(対象タグは未解決のまま、update status は `needed`)。空値は未設定であり既定が正しいため対象外。update status と同じ startup 限定(Codex 版)/全 matcher(Claude 版)の分岐に従う。正規化(`Latest` → `latest`)を採らずに値を名指しする理由は、正規化では `lattest` のような打ち間違いが依然として黙って既定へ倒れるため、「黙って倒れる」クラス全体を塞げないことによる(issue #1804)。 -- **`━━━ Li+ language contract ━━━`**:`LI_PLUS_BASE_LANGUAGE=<値>` / `LI_PLUS_PROJECT_LANGUAGE=<値>` を出力する。値は上記3軸 verify の軸3が Li+config.md から抽出したものをそのまま使う(毎セッション実行時に live な config から取得しており、生成ファイルへの焼き込みではない)。未解決の値は `unset` として出力し、ブロック自体は(下記 pre-bootstrap 早期終了に到達しない限り)無条件に emit する — bootstrap 済みセッションの内側では「ブロックが無い」状態と「値が無い」状態を AI 側が区別せずに済むため。この marker が在る理由は、契約本文(`Workspace_Language_Contract`)が常時 context に在るのに対し、その**値**の解決だけが「Li+config.md を読む」という手続きとして書かれており、同ファイルはどのエージェントの context にも自動ロードされないため(issue #1575)。契約の意味論(base / project の定義と precedence)の正本は `adapter/*/CLAUDE.md`・`AGENTS.md` の `Workspace_Language_Contract` であり、本 marker は値のみを運ぶ。Codex 版では update status が startup matcher 限定なのに対し、本 marker は Claude 版と同じく全 matcher で emit する(rules 再注入と同じ理由で、非 startup の matcher でも値が context に要る)。 +- **`━━━ Li+ language contract ━━━`**:`LI_PLUS_BASE_LANGUAGE=<値>` / `LI_PLUS_PROJECT_LANGUAGE=<値>` を出力する。値は上記4軸 verify の軸3が Li+config.md から抽出したものをそのまま使う(毎セッション実行時に live な config から取得しており、生成ファイルへの焼き込みではない)。未解決の値は `unset` として出力し、ブロック自体は(下記 pre-bootstrap 早期終了に到達しない限り)無条件に emit する — bootstrap 済みセッションの内側では「ブロックが無い」状態と「値が無い」状態を AI 側が区別せずに済むため。この marker が在る理由は、契約本文(`Workspace_Language_Contract`)が常時 context に在るのに対し、その**値**の解決だけが「Li+config.md を読む」という手続きとして書かれており、同ファイルはどのエージェントの context にも自動ロードされないため(issue #1575)。契約の意味論(base / project の定義と precedence)の正本は `adapter/*/CLAUDE.md`・`AGENTS.md` の `Workspace_Language_Contract` であり、本 marker は値のみを運ぶ。Codex 版では update status が startup matcher 限定なのに対し、本 marker は Claude 版と同じく全 matcher で emit する(rules 再注入と同じ理由で、非 startup の matcher でも値が context に要る)。 - **`━━━ gh install ━━━`**:`gh` CLI が PATH 上に無い場合のみ出力する(`command -v gh` が真を返せばブロック自体が実行されず、マーカーも出ない)。ブートストラップ済み通常セッションに限定すると、ブロックに入った場合の結果は `GH_INSTALL_STATUS=installed` / `failed: <末尾ログ抜粋>` / `missing: <ホスト別ガイダンス>` のいずれかであり、この3値すべてでマーカーは出力される(`installed` も無言スキップではない)。pre-bootstrap 経路では出力条件がこれと異なる(下記「pre-bootstrap 早期終了」参照。zero-output ケースはそちらにもう1つ存在する)。Linux ホストはアーキテクチャ判定つきで `~/.local/bin/gh` へ自動インストールを試み、成否を `installed`/`failed` として報告する。macOS と Windows(Git-Bash・MSYS2・Cygwin) はドキュメント化された前提条件として扱い、自動インストールせず具体的な導入コマンド(`brew install gh` / `winget install --id GitHub.cli`)を案内する。認識できないホストカーネル(default ブランチ)は、具体的なコマンドは示さず「プラットフォームのパッケージマネージャーで導入せよ」という一般的な案内のみを出す。 pre-bootstrap 早期終了:`liplus-language` リポジトリが未解決(bootstrap 前)の段階では、hook は gh install チェックの直後、matcher 解決や更新同期 verify に入る前に exit する。このガードは `GH_INSTALL_STATUS` が `failed` または `missing` で始まる場合のみマーカーを出力する。したがって、この経路では `gh` が既に PATH 上にある場合に加え、Linux 自動インストールが成功した場合(`GH_INSTALL_STATUS=installed`)も無出力のまま hook が終了する — 「ブートストラップ済み通常セッションでは installed も出力される」の対象外にあたる、もう1つの zero-output ケースである。Li+ update status マーカー、Li+config: unrecognized value マーカー、Li+ language contract マーカーはこの経路では一切出力されない。したがって bootstrap 前のセッションでは言語値が context に届かない — この状態の扱い(marker 不在も `unset` と同じく human へ確認する)は `adapter/*/CLAUDE.md`・`AGENTS.md` の `Workspace_Language_Contract` が持つ。 diff --git a/docs/C.-Update.md b/docs/C.-Update.md index 4326b58a..ef5ea256 100644 --- a/docs/C.-Update.md +++ b/docs/C.-Update.md @@ -1,7 +1,7 @@ # 更新同期手続き仕様書 本文書は Li+ のアダプター / 設定の更新同期手続き(`Li+update.md`)の仕様を定義する。 -Li+config.md の設定値を前提とし、アダプター sentinel tag・Li+config schema・workspace 言語契約のいずれかが目標状態から逸脱した時に AI が実行する Phase を記述する。 +Li+config.md の設定値を前提とし、アダプター sentinel tag・Li+config schema・workspace 言語契約のいずれかが目標状態から逸脱した時、または clone モードの clone が branch を fetch できない状態にある時に AI が実行する Phase を記述する。 --- @@ -9,7 +9,7 @@ Li+config.md の設定値を前提とし、アダプター sentinel tag・Li+con 更新同期手続きは **`Li+update.md`** に定義されている。Li+config.md はユーザー設定のみを保持し、同期ロジックは分離されている。 -`on-session-start.sh` hook が 3 軸(adapter sentinel tag / Li+config schema / 言語契約)を verify し、いずれかが drift していれば `LI_PLUS_UPDATE_STATUS=needed` を emit する。AI はこの marker を見て本手続きを実行するか判定する。大半のセッションでは `LI_PLUS_UPDATE_STATUS=unnecessary` となり、本手続きは走らない(旧称「セッション起動フロー」が現運用とずれていたため、v1.17.10 で「更新同期手続き」へ rename した)。 +`on-session-start.sh` hook が 4 軸(adapter sentinel tag / Li+config schema / 言語契約 / clone の branch fetch 可否)を verify し、いずれかが drift していれば `LI_PLUS_UPDATE_STATUS=needed` を emit する。AI はこの marker を見て本手続きを実行するか判定する。大半のセッションでは `LI_PLUS_UPDATE_STATUS=unnecessary` となり、本手続きは走らない(旧称「セッション起動フロー」が現運用とずれていたため、v1.17.10 で「更新同期手続き」へ rename した)。 AI は Li+config.md を読み込んだ後、`Li+update.md` の Phase 1 から Phase 6 を順に実行する。各 Phase は直前までの Phase を依存前提として宣言する。認証情報をチャットに出力してはいけない。 @@ -104,11 +104,12 @@ host OS は adapter 種別(runtime=claude / runtime=codex)から推測しな `git -C {workspace_root}/{repo_dir} checkout {target_tag}` どちらも実行する literal そのものであり、フラグを追加しない - 存在する → `fetch --tags` を実行し: - a. 現在 checkout 中のタグと、`LI_PLUS_CHANNEL` から解決した対象タグを両方確認して報告する。その際、どちらが新しいかを名指す。channel によっては対象タグが現在タグより古いことがあり、対象であることから新しさは導けない - b. 一致する場合はそのまま続行 - c. 不一致の場合、Phase 4 へ進む前に人間にどうするか確認する。この選択が解決するまで bootstrap 完了扱いにしない。最小選択肢は「対象タグへ更新してから続行」「今セッションは現在タグのまま続行」 - d. 人間が更新に同意した場合のみ対象タグへ checkout - e. 現在タグのまま続行を選んだ場合は、現在タグと対象タグを明示してから続行 + a. clone が branch を fetch できるかを確認する。`git -C {workspace_root}/{repo_dir} config --get-all remote.origin.fetch` が、source 側を `refs/heads/` 以下に持つ refspec を最低 1 本保持していること。1 本も無い clone でも tag は解決するため、直前の `fetch --tags` は成功したまま branch はどれも動かず、後続の素の `git fetch origin` もエラーではなく no-op として成功する。無い場合は人間にその事実を名指し、代償も名指す(ローカル branch が永久に進まないため、ローカル branch から生やした worktree やビルドは古い木から取られる)。**検出のみ**であり、refspec の追加も re-clone も行わず、中断もしない(b へ続行する)。修理は人間の側にある(共有されたローカル git state を書き換えるため、エージェントが独断で踏まない)。同じ検査は on-session-start hook の 3 port が verify 軸 4 として毎セッション実行しており、それが `LI_PLUS_UPDATE_STATUS` marker の `clone-refspec-no-branch-mapping` reason になる。clone がその状態に留まる間、marker は毎セッション `needed` を報告し本手続きが再走する(この持続は escalation であり、本手続きが解消すべき drift ではない) + b. 現在 checkout 中のタグと、`LI_PLUS_CHANNEL` から解決した対象タグを両方確認して報告する。その際、どちらが新しいかを名指す。channel によっては対象タグが現在タグより古いことがあり、対象であることから新しさは導けない + c. 一致する場合はそのまま続行 + d. 不一致の場合、Phase 4 へ進む前に人間にどうするか確認する。この選択が解決するまで bootstrap 完了扱いにしない。最小選択肢は「対象タグへ更新してから続行」「今セッションは現在タグのまま続行」 + e. 人間が更新に同意した場合のみ対象タグへ checkout + f. 現在タグのまま続行を選んだ場合は、現在タグと対象タグを明示してから続行 3. 解決済みタグでソースファイルが参照可能な状態になる。読み込みは Phase 4 が担う --- diff --git a/tests/test_clone_refspec_branch_check.py b/tests/test_clone_refspec_branch_check.py new file mode 100644 index 00000000..86127339 --- /dev/null +++ b/tests/test_clone_refspec_branch_check.py @@ -0,0 +1,196 @@ +"""Behavioural coverage for the clone-mode branch-fetch check. + +Target = the three `adapter/*/hooks/on-session-start.*` implementations +(claude bash / codex bash / codex PowerShell) and the two prose surfaces that +carry the same decision. Issue #1911. + +The defect this pins: `remote.origin.fetch` decides which refs a fetch moves, +and a clone configured with tag mappings only still resolves tags. So +`fetch --tags` on Li+update.md's clone-mode `exists` path succeeds, a later bare +`git fetch origin` succeeds as a no-op, and the sentinel-tag axis -- which reads +tags -- keeps emitting `unnecessary` while every branch stays where it was. One +such clone sat 374 commits behind for close to five months, and it surfaced by +accident rather than through any check. + +What is pinned +-------------- +Detection, and only detection: the axis reports +`clone-refspec-no-branch-mapping` on the `LI_PLUS_UPDATE_STATUS` reason surface +and repairs nothing, so the assertions read the reason out of the marker and +also assert the fixture's refspec configuration is unchanged by the run. The +predicate is "at least one refspec whose source side is under `refs/heads/`", +so a single-branch clone passes -- it does track a branch. Two states are +silent because neither is evidence about a refspec: a directory that is not a +clone (api mode), and a host without `git`. + +The reason token is asserted across all five surfaces because a port left +behind makes the same workspace report differently depending on which host +adapter ran, which is the shape #1804 produced once already. +""" + +from __future__ import annotations + +import shutil +import subprocess +import unittest +from pathlib import Path + +from test_on_session_start_observation_surface import ( + ADAPTERS, + Workspace, + require_runtime, +) +from test_config_value_parity import update_status_line + + +ROOT = Path(__file__).resolve().parents[1] + +REASON = "clone-refspec-no-branch-mapping" + +WILDCARD = "+refs/heads/*:refs/remotes/origin/*" +SINGLE_BRANCH = "+refs/heads/main:refs/remotes/origin/main" +TAG_ONLY = "+refs/tags/build-2026-04-12.8:refs/tags/build-2026-04-12.8" + +# The three hook ports, plus the spec they implement and the doc that mirrors it. +SURFACES = ( + "adapter/claude/hooks/on-session-start.sh", + "adapter/codex/hooks/on-session-start.sh", + "adapter/codex/hooks/on-session-start.ps1", + "Li+update.md", + "docs/C.-Update.md", +) + +GIT = shutil.which("git") + + +def make_clone(directory: Path, *refspecs: str) -> None: + """A real repository at `directory` carrying exactly `refspecs`. + + `git remote add` writes the wildcard mapping itself, so it is removed first + and the fixture's own set added back. No refspec at all is a valid fixture: + it is the state a clone reaches when the key is dropped entirely. + """ + directory.mkdir(parents=True, exist_ok=True) + run = lambda *args: subprocess.run( # noqa: E731 + [GIT, "-C", str(directory), *args], + check=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + subprocess.run( + [GIT, "init", "-q", str(directory)], + check=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + run("remote", "add", "origin", "https://example.invalid/liplus-language.git") + subprocess.run( + [GIT, "-C", str(directory), "config", "--unset-all", "remote.origin.fetch"], + check=False, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + for refspec in refspecs: + run("config", "--add", "remote.origin.fetch", refspec) + + +def configured_refspecs(directory: Path) -> list[str]: + completed = subprocess.run( + [GIT, "-C", str(directory), "config", "--get-all", "remote.origin.fetch"], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + return completed.stdout.decode("utf-8").split() + + +class CloneRefspecParityTestCase(unittest.TestCase): + """The reason token reaches every surface carrying this decision.""" + + def test_every_surface_names_the_reason(self) -> None: + for surface in SURFACES: + with self.subTest(surface=surface): + text = (ROOT / surface).read_text(encoding="utf-8") + self.assertIn(REASON, text) + + def test_every_port_reads_the_fetch_refspec(self) -> None: + """A port that emits the token without reading the config is not a check.""" + for surface in SURFACES[:3]: + with self.subTest(surface=surface): + text = (ROOT / surface).read_text(encoding="utf-8") + self.assertIn("remote.origin.fetch", text) + self.assertIn("refs/heads/", text) + + +class CloneRefspecBranchCheckTestCase(unittest.TestCase): + def setUp(self) -> None: + if not GIT: + require_runtime("git", "clone refspec branch check") + self.ws = Workspace() + self.addCleanup(self.ws.cleanup) + # Past the codex ports' unresolved-source guard; without it those two + # hooks exit before any Li+ marker is emitted. + self.ws.seed_coldstart_rule("CLONE-REFSPEC-FIXTURE") + + def reasons(self, adapter: str) -> str: + output = self.ws.run(adapter, "startup") + self.ws.clear_state() + line = update_status_line(output) + self.assertIsNotNone(line, f"{adapter} emitted no update status marker") + return line + + def assertReported(self, adapter: str) -> None: + self.assertIn(REASON, self.reasons(adapter)) + + def assertSilent(self, adapter: str) -> None: + self.assertNotIn(REASON, self.reasons(adapter)) + + def test_tag_only_refspec_is_reported(self) -> None: + """The measured state: tags resolve, no branch mapping exists.""" + make_clone(self.ws.liplus, TAG_ONLY) + for adapter in ADAPTERS: + with self.subTest(adapter=adapter): + self.assertReported(adapter) + + def test_no_refspec_at_all_is_reported(self) -> None: + make_clone(self.ws.liplus) + for adapter in ADAPTERS: + with self.subTest(adapter=adapter): + self.assertReported(adapter) + + def test_wildcard_refspec_is_silent(self) -> None: + make_clone(self.ws.liplus, WILDCARD) + for adapter in ADAPTERS: + with self.subTest(adapter=adapter): + self.assertSilent(adapter) + + def test_single_branch_refspec_is_silent(self) -> None: + """A single-branch clone tracks a branch, so it is not this defect.""" + make_clone(self.ws.liplus, SINGLE_BRANCH) + for adapter in ADAPTERS: + with self.subTest(adapter=adapter): + self.assertSilent(adapter) + + def test_tags_alongside_a_branch_are_silent(self) -> None: + """The wildcard's presence decides, not the count of tag mappings.""" + make_clone(self.ws.liplus, TAG_ONLY, WILDCARD) + for adapter in ADAPTERS: + with self.subTest(adapter=adapter): + self.assertSilent(adapter) + + def test_directory_that_is_not_a_clone_is_silent(self) -> None: + """api mode: the directory exists and holds no repository.""" + for adapter in ADAPTERS: + with self.subTest(adapter=adapter): + self.assertSilent(adapter) + + def test_detection_does_not_repair(self) -> None: + """No port writes the missing refspec back.""" + make_clone(self.ws.liplus, TAG_ONLY) + for adapter in ADAPTERS: + with self.subTest(adapter=adapter): + self.reasons(adapter) + self.assertEqual(configured_refspecs(self.ws.liplus), [TAG_ONLY]) + + +if __name__ == "__main__": + unittest.main() From 200f092fce5e5125347d82dcae0e323c5747b41a Mon Sep 17 00:00:00 2001 From: Yoshiharu Uematsu Date: Wed, 9 Sep 2026 02:09:39 +0900 Subject: [PATCH 2/4] style(hooks): drop provenance-only sentence from the ps1 axis 4 comment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit brake 1 round 1 の fixed axis(impression-literal detection)の指摘を受理。 `adapter/codex/hooks/on-session-start.ps1` の `-cmatch` コメント末尾 `#1804 is the same split, on another value.` を removal test にかけたところ、 除去しても rule の behavior semantic は変わらない。残る前半が要求 (ref 名は case-sensitive、bash 2 port は case-sensitive な grep)を それ自体の言葉で述べており、revision stability も変わらない。 #1911 Co-Authored-By: Claude Opus 5 --- adapter/codex/hooks/on-session-start.ps1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/adapter/codex/hooks/on-session-start.ps1 b/adapter/codex/hooks/on-session-start.ps1 index f449d933..d286229d 100644 --- a/adapter/codex/hooks/on-session-start.ps1 +++ b/adapter/codex/hooks/on-session-start.ps1 @@ -238,7 +238,7 @@ if ($matcher -ceq 'startup') { if ((Test-Path -LiteralPath (Join-Path $liplusDir '.git')) -and (Get-Command git -ErrorAction SilentlyContinue)) { $fetchRefspecs = git -C $liplusDir config --get-all remote.origin.fetch 2>$null # -cmatch, not -match: git ref names are case-sensitive and the two bash - # ports use a case-sensitive grep. #1804 is the same split, on another value. + # ports use a case-sensitive grep. if (-not ($fetchRefspecs -cmatch 'refs/heads/')) { $updateReasons += 'clone-refspec-no-branch-mapping' } From 51103bbb97f476b64fb7d1b46e4d6579d70179a7 Mon Sep 17 00:00:00 2001 From: Yoshiharu Uematsu Date: Wed, 9 Sep 2026 02:28:00 +0900 Subject: [PATCH 3/4] spec(cold-start): move the clone branch-fetch finding off the update marker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 報告先の設計変更(issue #1911 の改訂された 解 / target files)。述語と 「検出のみ、修理はしない」の立場は変わらない。 `LI_PLUS_UPDATE_STATUS` は報告面ではなく adapter startup 手順 step 2 の 起動条件であり、reason による分岐を持たない。健全性の欠陥をそこに積むと、 検出のみのはずの検査が更新手続きを起動する経路を持つ。普通の diff-only section は逆向きに外す —— body が変わらないため初回だけ出て、慢性化した 瞬間に黙る。 載せ先は section key を持たず diff-only の外に置かれる surface class (Promotion Tally Expiry Surface / Self-Evolution Observation Surface と同じ)。 - `rules/evolution/cold-start-synthesis.md` — Clone Branch Fetch Surface 節を 新設。trigger 種は 3 つ目で、既存 2 面の date-driven に対し state-driven。 借りるのは surface class の性質だけで日付の語彙は借りない。lifecycle を 持たないこと、その不在が欠落ではないこと(足すと条件消失後も残るエントリを 生む)を明記。Hook Emission Contract の coordination 一覧と marker 抑止条件も更新 - hook 3 port — 軸 4 を撤去し、tally surface の隣で emit する block を追加。 no-new-material marker の抑止条件に加算 - `Li+update.md` / `docs/C.-Update.md` — `exists` 経路 step a の報告先を差し替え - `adapter/claude/CLAUDE.md` / `adapter/codex/AGENTS.md` — `unnecessary` の 保証列挙を改訂前へ戻す(載せ先が marker でなくなったため対象外) - `docs/6.-Adapter.md` / `docs/2.-Evolution.md` — surface 一覧、marker 抑止、 diff-only 除外理由(state 駆動という別向きの非対称)を更新 - tests — surface の behavioural(3 port)に加え、marker に載っていないこと、 2 回目の startup でも黙らないこと(対照として健全 clone では marker が出ること)、 lifecycle 不在が本文に書かれていることを pin #1911 Co-Authored-By: Claude Opus 5 --- Li+update.md | 10 +- adapter/claude/CLAUDE.md | 2 +- adapter/claude/hooks/on-session-start.sh | 82 +++++---- adapter/codex/AGENTS.md | 2 +- adapter/codex/hooks/on-session-start.ps1 | 52 ++++-- adapter/codex/hooks/on-session-start.sh | 65 ++++++-- docs/2.-Evolution.md | 8 +- docs/6.-Adapter.md | 9 +- docs/C.-Update.md | 6 +- rules/evolution/cold-start-synthesis.md | 33 +++- tests/test_clone_refspec_branch_check.py | 201 ++++++++++++++++++----- 11 files changed, 348 insertions(+), 122 deletions(-) diff --git a/Li+update.md b/Li+update.md index 85ac55d6..4bf37ede 100644 --- a/Li+update.md +++ b/Li+update.md @@ -1,7 +1,7 @@ # Li+ Update Adapter / configuration sync procedure for Li+. -Invoked when the adapter sentinel tag, Li+config schema, or workspace language contract drifts from the target state, or when a clone-mode workspace holds a clone that cannot fetch branches (the on-session-start.sh hook emits `LI_PLUS_UPDATE_STATUS=needed` in each of those cases). Most sessions skip this file because the hook reports `LI_PLUS_UPDATE_STATUS=unnecessary`. +Invoked when the adapter sentinel tag, Li+config schema, or workspace language contract drifts from the target state (the on-session-start.sh hook emits `LI_PLUS_UPDATE_STATUS=needed` in that case). Most sessions skip this file because the hook reports `LI_PLUS_UPDATE_STATUS=unnecessary`. Never output credentials to chat. Read Li+config.md first to resolve all settings before executing this file. Phases execute in order. Each phase declares its dependencies. @@ -136,11 +136,9 @@ clone mode: advance, so a worktree or a build taken from a local branch is taken from a stale tree. Detection only. Do not add the refspec, do not re-clone, do not abort — continue to step b either way. The repair is the user's: it writes shared local git state, which no agent takes on its own. - The same check runs every session as verification axis 4 of the three on-session-start hook ports, - which is what puts it on the `LI_PLUS_UPDATE_STATUS` marker as the - `clone-refspec-no-branch-mapping` reason. While the clone stays in that state the marker reports - `needed` every session and this walkthrough runs again; that persistence is the escalation, not a - drift the walkthrough is expected to clear. + The same condition is surfaced every session by the on-session-start hooks, which is where a clone + that stays in this state keeps being reported; the destination, and why it is not + `LI_PLUS_UPDATE_STATUS`, are `rules/evolution/cold-start-synthesis.md` Clone Branch Fetch Surface. b. Resolve and report both values: current checked-out tag and target tag from LI_PLUS_CHANNEL. Name which of the two is newer: the target is not necessarily the newer one, since a channel can resolve to a tag behind the current one. diff --git a/adapter/claude/CLAUDE.md b/adapter/claude/CLAUDE.md index 71747d82..561814ca 100644 --- a/adapter/claude/CLAUDE.md +++ b/adapter/claude/CLAUDE.md @@ -20,7 +20,7 @@ Concept framing (Sheepdog Engineering): Execute the following at startup (never output credentials to chat): 1. Inspect the `LI_PLUS_UPDATE_STATUS=` marker emitted by `on-session-start.sh` (delimited by `━━━ Li+ update status ━━━` banner) in the session-opening context. - - `LI_PLUS_UPDATE_STATUS=unnecessary` -> skip step 2 entirely (no Li+update.md re-execution). The hook has verified adapter sentinel tag matches the target tag, Li+config schema is canonical, language contract is resolved, and the Li+ clone can fetch branches. On-demand spot read of Li+config.md for value lookup (e.g. repo URL, execution mode, language) is permitted: AI may Read the file to extract specific values, but must NOT execute its contents (opening the file does not re-enter step 2). Li+update.md re-execution remains prohibited. + - `LI_PLUS_UPDATE_STATUS=unnecessary` -> skip step 2 entirely (no Li+update.md re-execution). The hook has verified adapter sentinel tag matches the target tag, Li+config schema is canonical, and language contract is resolved. On-demand spot read of Li+config.md for value lookup (e.g. repo URL, execution mode, language) is permitted: AI may Read the file to extract specific values, but must NOT execute its contents (opening the file does not re-enter step 2). Li+update.md re-execution remains prohibited. - `LI_PLUS_UPDATE_STATUS=needed` (or marker absent) -> proceed to step 2. - Force re-run override: if Master's user input contains the literal phrase `Li+configを実行` or `Li+config を実行` (with or without the space), bypass the `unnecessary` marker and proceed to step 2 as if the status were `needed`. 2. Read Li+config.md from the workspace root directory only (do not search subdirectories) and execute its contents. (Ask the user for confirmation if needed during execution) diff --git a/adapter/claude/hooks/on-session-start.sh b/adapter/claude/hooks/on-session-start.sh index f5efa9c3..fe0776f6 100755 --- a/adapter/claude/hooks/on-session-start.sh +++ b/adapter/claude/hooks/on-session-start.sh @@ -186,14 +186,13 @@ fi # 99% of sessions have no tag change, no schema change, and all config values # resolved — verification only, no actual changes. The walkthrough costs ~4% # context (10% with Li+config execution vs 6% without). The hook performs the -# four verifications and emits a single-line update status marker the AI +# three verifications and emits a single-line update status marker the AI # parses to decide whether to read Li+config + Li+update at all. # -# Four axes (ALL must pass for "unnecessary"): +# Three axes (ALL must pass for "unnecessary"): # 1. adapter sentinel tag in .claude/CLAUDE.md == current LI_PLUS_REPO target tag (per LI_PLUS_CHANNEL) # 2. Li+config.md schema canonical (no legacy keys present) # 3. LI_PLUS_BASE_LANGUAGE and LI_PLUS_PROJECT_LANGUAGE resolved (non-comment, non-empty) -# 4. clone mode: remote.origin.fetch maps at least one branch (detection only; the hook does not repair) # # Marker format (machine/AI-parseable, single line + optional reason): # LI_PLUS_UPDATE_STATUS=unnecessary -> AI skips Li+update walkthrough; Li+config spot read (Read for value lookup, no execute) is permitted @@ -268,27 +267,6 @@ if [ -z "$BASE_LANG" ] || [ -z "$PROJ_LANG" ]; then UPDATE_REASONS+=("language-contract-unresolved(base=${BASE_LANG:-unset},project=${PROJ_LANG:-unset})") fi -# --- axis 4: clone can fetch branches (#1911) --- -# A clone whose `remote.origin.fetch` carries no `refs/heads/` source still -# resolves tags, so the `fetch --tags` on Li+update.md's clone-mode `exists` -# path succeeds while every branch stays where it was, and a later bare -# `git fetch origin` is a silent no-op rather than an error. No other axis here -# reports that: axis 1 reads tags, which are exactly what such a clone does -# advance, so it keeps emitting "unnecessary" while the branch lag accumulates -# with no surface at all. -# Detection only. The repair writes shared local git state, which is `high` -# caution under `rules/evolution/memory-entry-format.md` Artifact deletion -# calibration and stays with the human; this axis names the state and stops. -# The marker therefore reports "needed" every session until the human repairs -# the clone -- that persistence is the escalation, not a defect in the axis. -# Silent skip when the directory is not a clone (api mode) or `git` is absent: -# neither state is evidence about a refspec. -if [ -e "$LIPLUS_DIR/.git" ] && command -v git >/dev/null 2>&1; then - if ! git -C "$LIPLUS_DIR" config --get-all remote.origin.fetch 2>/dev/null | grep -q 'refs/heads/'; then - UPDATE_REASONS+=("clone-refspec-no-branch-mapping") - fi -fi - # --- emit update status marker --- # Always emit first, before any cold-start material, so AI parses it before # deciding whether to read Li+config.md and Li+update.md. @@ -1177,6 +1155,53 @@ if [ -n "$TALLY_BODY" ]; then TALLY_EMITTED=1 fi +# --- clone branch fetch surface (outside the diff-only set) --- +# Implements rules/evolution/cold-start-synthesis.md "Clone Branch Fetch +# Surface" (issue #1911). A Li+ clone whose remote.origin.fetch carries no +# refspec sourced under refs/heads/ resolves tags and moves no branch, and +# nothing raises: `fetch --tags` succeeds and a bare `git fetch origin` is a +# silent no-op, so the lag accumulates with no surface at all. +# +# Carries no section key and is not registered for diff comparison, like the two +# surfaces above, and for the same asymmetry: the trigger is not content-driven, +# so a fingerprint would surface the clone once and then suppress it for the +# whole time it stays broken. Unlike those two the trigger is state-driven, not +# date-driven, which is why nothing here reads a date and nothing has to be +# removed: the emission stops when the condition stops holding. +# +# Not placed on LI_PLUS_UPDATE_STATUS: that marker is the trigger condition for +# step 2 of the adapter startup procedure and branches on the status alone, so a +# reason stacked there would let a detection-only check start the update +# walkthrough. +# +# Gathered here rather than in the gather phase because it is not in the diff +# set, so its body is needed only at this point, and this point is past the +# non-startup exit -- the `git` call is not spent on resume / clear / compact / +# fork. Silent skip when the directory is not a clone (api mode) or `git` is +# absent: neither state is evidence about a refspec. +CLONE_REFSPEC_EMITTED=0 +if [ -e "$LIPLUS_DIR/.git" ] && command -v git >/dev/null 2>&1; then + CLONE_FETCH_REFSPECS=$(git -C "$LIPLUS_DIR" config --get-all remote.origin.fetch 2>/dev/null | tr ' +' ' ' | sed 's/[[:space:]]*$//') + case " $CLONE_FETCH_REFSPECS " in + *refs/heads/*) ;; + *) + emit_section "Clone cannot fetch branches" "${LIPLUS_DIR} - remote.origin.fetch maps no branch. + configured: ${CLONE_FETCH_REFSPECS:-(none)} + expected: at least one refspec whose source side is under refs/heads/ +Tags still resolve, so fetch --tags succeeds and a bare git fetch origin is a +silent no-op; local branches never advance, so a worktree or a build taken from +a local branch is taken from a stale tree. +Surfacing is observation, not auto-action. The repair (one git config --add +remote.origin.fetch '+refs/heads/*:refs/remotes/origin/*' line) writes shared +local git state and is taken on a human go-sign; name this to the human and stop +there. Contract = rules/evolution/cold-start-synthesis.md Clone Branch Fetch +Surface." + CLONE_REFSPEC_EMITTED=1 + ;; + esac +fi + # --- diff-only logic (startup matcher only) --- # # Compute current fingerprint per section. Load prior fingerprint set from @@ -1301,10 +1326,11 @@ done # If no section emitted under diff-only mode, emit the no-new-material marker # so the human can still observe that a session boundary occurred (silent # skip is intentionally avoided — it would hide the session transition). -# The two date-driven surfaces count as material: pairing a just-emitted overdue -# entry or an expired tally cluster with "No new orientation material" would be -# self-contradictory output. -if [ "$EMITTED_ANY" -eq 0 ] && [ "$OBSERVATION_EMITTED" -eq 0 ] && [ "$TALLY_EMITTED" -eq 0 ] && [ "$FAIL_SAFE_FULL_EMIT" -eq 0 ]; then +# The three surfaces outside the diff-only set count as material: pairing a +# just-emitted overdue entry, an expired tally cluster or a clone that cannot +# fetch branches with "No new orientation material" would be self-contradictory +# output. +if [ "$EMITTED_ANY" -eq 0 ] && [ "$OBSERVATION_EMITTED" -eq 0 ] && [ "$TALLY_EMITTED" -eq 0 ] && [ "$CLONE_REFSPEC_EMITTED" -eq 0 ] && [ "$FAIL_SAFE_FULL_EMIT" -eq 0 ]; then emit_section "Orientation diff" "No new orientation material since last session. Prior in-context state remains authoritative." MARKER_EMITTED=1 fi diff --git a/adapter/codex/AGENTS.md b/adapter/codex/AGENTS.md index 1f2d6f02..ec467933 100644 --- a/adapter/codex/AGENTS.md +++ b/adapter/codex/AGENTS.md @@ -20,7 +20,7 @@ Concept framing (Sheepdog Engineering): Execute the following at startup (never output credentials to chat): 1. Inspect the `LI_PLUS_UPDATE_STATUS=` marker emitted by the `on-session-start` SessionStart hook (delimited by the `━━━ Li+ update status ━━━` banner) in the session-opening context. - - `LI_PLUS_UPDATE_STATUS=unnecessary` -> skip step 2 entirely. The hook has verified adapter sentinel tag matches the target tag, Li+config schema is canonical, the language contract is resolved, and the Li+ clone can fetch branches. On-demand spot read of Li+config.md for value lookup (repo URL, execution mode, language) is permitted: Read the file to extract values, but do NOT execute its contents. + - `LI_PLUS_UPDATE_STATUS=unnecessary` -> skip step 2 entirely. The hook has verified adapter sentinel tag matches the target tag, Li+config schema is canonical, and the language contract is resolved. On-demand spot read of Li+config.md for value lookup (repo URL, execution mode, language) is permitted: Read the file to extract values, but do NOT execute its contents. - `LI_PLUS_UPDATE_STATUS=needed` (or marker absent) -> proceed to step 2. - Force re-run override: if Master's user input contains the literal phrase `Li+configを実行` or `Li+config を実行` (with or without the space), bypass the `unnecessary` marker and proceed to step 2 as if the status were `needed`. - Marker-absent fallback: if the marker is missing (hook not trusted yet, or pre-bootstrap), treat as `needed`. The marker is absent whenever the SessionStart hook did not run — most commonly because the one-time GUI trust has not been granted (see Rules: hook trust below). diff --git a/adapter/codex/hooks/on-session-start.ps1 b/adapter/codex/hooks/on-session-start.ps1 index d286229d..59068352 100644 --- a/adapter/codex/hooks/on-session-start.ps1 +++ b/adapter/codex/hooks/on-session-start.ps1 @@ -232,18 +232,6 @@ if ($matcher -ceq 'startup') { $updateReasons += "language-contract-unresolved(base=$b,project=$p)" } - # --- axis 4: clone can fetch branches (#1911) --- - # Rationale is in the claude port this one mirrors. Detection only; silent - # skip when the directory is not a clone or `git` is absent. - if ((Test-Path -LiteralPath (Join-Path $liplusDir '.git')) -and (Get-Command git -ErrorAction SilentlyContinue)) { - $fetchRefspecs = git -C $liplusDir config --get-all remote.origin.fetch 2>$null - # -cmatch, not -match: git ref names are case-sensitive and the two bash - # ports use a case-sensitive grep. - if (-not ($fetchRefspecs -cmatch 'refs/heads/')) { - $updateReasons += 'clone-refspec-no-branch-mapping' - } - } - # --- emit update status marker --- if ($updateReasons.Count -eq 0) { Emit '━━━ Li+ update status ━━━' @@ -1006,6 +994,37 @@ if ($tallyBody) { $tallyEmitted = $true } +# --- clone branch fetch surface (outside the diff-only set) --- +# Implements rules/evolution/cold-start-synthesis.md "Clone Branch Fetch +# Surface" (#1911). Port of the same block in +# adapter/claude/hooks/on-session-start.sh; the rationale for the placement, for +# staying out of the diff set, and for staying off LI_PLUS_UPDATE_STATUS is +# there. State-driven trigger, so nothing here reads a date and nothing has to +# be removed: the emission stops when the condition stops holding. +# -cmatch, not -match: git ref names are case-sensitive and the two bash ports +# compare case-sensitively. +$cloneRefspecEmitted = $false +if ((Test-Path -LiteralPath (Join-Path $liplusDir '.git')) -and (Get-Command git -ErrorAction SilentlyContinue)) { + $cloneFetchRefspecs = @(git -C $liplusDir config --get-all remote.origin.fetch 2>$null) + if (-not ($cloneFetchRefspecs -cmatch 'refs/heads/')) { + $configured = if ($cloneFetchRefspecs -and ($cloneFetchRefspecs -join ' ').Trim()) { ($cloneFetchRefspecs -join ' ').Trim() } else { '(none)' } + Emit-Section 'Clone cannot fetch branches' @" +$liplusDir - remote.origin.fetch maps no branch. + configured: $configured + expected: at least one refspec whose source side is under refs/heads/ +Tags still resolve, so fetch --tags succeeds and a bare git fetch origin is a +silent no-op; local branches never advance, so a worktree or a build taken from +a local branch is taken from a stale tree. +Surfacing is observation, not auto-action. The repair (one git config --add +remote.origin.fetch '+refs/heads/*:refs/remotes/origin/*' line) writes shared +local git state and is taken on a human go-sign; name this to the human and stop +there. Contract = rules/evolution/cold-start-synthesis.md Clone Branch Fetch +Surface. +"@ + $cloneRefspecEmitted = $true + } +} + # =================================================================== # Diff-only emission (startup matcher) # =================================================================== @@ -1052,11 +1071,12 @@ for ($i = 0; $i -lt $sectionKeys.Count; $i++) { } } -# The two date-driven surfaces count as material: pairing a just-emitted overdue -# entry or an expired tally cluster with "No new orientation material" would be -# self-contradictory output. +# The three surfaces outside the diff-only set count as material: pairing a +# just-emitted overdue entry, an expired tally cluster or a clone that cannot +# fetch branches with "No new orientation material" would be self-contradictory +# output. $markerEmitted = $false -if (-not $emittedAny -and -not $observationEmitted -and -not $tallyEmitted -and -not $failSafeFull) { +if (-not $emittedAny -and -not $observationEmitted -and -not $tallyEmitted -and -not $cloneRefspecEmitted -and -not $failSafeFull) { Emit-Section 'Orientation diff' 'No new orientation material since last session. Prior in-context state remains authoritative.' $markerEmitted = $true } diff --git a/adapter/codex/hooks/on-session-start.sh b/adapter/codex/hooks/on-session-start.sh index c8f906d3..d357a2a2 100644 --- a/adapter/codex/hooks/on-session-start.sh +++ b/adapter/codex/hooks/on-session-start.sh @@ -205,15 +205,6 @@ if [ "$MATCHER" = "startup" ]; then UPDATE_REASONS+=("language-contract-unresolved(base=${BASE_LANG:-unset},project=${PROJ_LANG:-unset})") fi - # axis 4: clone can fetch branches (#1911) - # Rationale is in the claude port this one mirrors. Detection only; silent - # skip when the directory is not a clone or `git` is absent. - if [ -e "$LIPLUS_DIR/.git" ] && command -v git >/dev/null 2>&1; then - if ! git -C "$LIPLUS_DIR" config --get-all remote.origin.fetch 2>/dev/null | grep -q 'refs/heads/'; then - UPDATE_REASONS+=("clone-refspec-no-branch-mapping") - fi - fi - if [ "${#UPDATE_REASONS[@]}" -eq 0 ]; then emit "━━━ Li+ update status ━━━" emit "LI_PLUS_UPDATE_STATUS=unnecessary tag=$TARGET_TAG channel=$LI_PLUS_CHANNEL_VAL" @@ -910,6 +901,53 @@ if [ -n "$TALLY_BODY" ]; then TALLY_EMITTED=1 fi +# --- clone branch fetch surface (outside the diff-only set) --- +# Implements rules/evolution/cold-start-synthesis.md "Clone Branch Fetch +# Surface" (issue #1911). A Li+ clone whose remote.origin.fetch carries no +# refspec sourced under refs/heads/ resolves tags and moves no branch, and +# nothing raises: `fetch --tags` succeeds and a bare `git fetch origin` is a +# silent no-op, so the lag accumulates with no surface at all. +# +# Carries no section key and is not registered for diff comparison, like the two +# surfaces above, and for the same asymmetry: the trigger is not content-driven, +# so a fingerprint would surface the clone once and then suppress it for the +# whole time it stays broken. Unlike those two the trigger is state-driven, not +# date-driven, which is why nothing here reads a date and nothing has to be +# removed: the emission stops when the condition stops holding. +# +# Not placed on LI_PLUS_UPDATE_STATUS: that marker is the trigger condition for +# step 2 of the adapter startup procedure and branches on the status alone, so a +# reason stacked there would let a detection-only check start the update +# walkthrough. +# +# Gathered here rather than in the gather phase because it is not in the diff +# set, so its body is needed only at this point, and this point is past the +# non-startup exit -- the `git` call is not spent on resume / clear / compact / +# fork. Silent skip when the directory is not a clone (api mode) or `git` is +# absent: neither state is evidence about a refspec. +CLONE_REFSPEC_EMITTED=0 +if [ -e "$LIPLUS_DIR/.git" ] && command -v git >/dev/null 2>&1; then + CLONE_FETCH_REFSPECS=$(git -C "$LIPLUS_DIR" config --get-all remote.origin.fetch 2>/dev/null | tr ' +' ' ' | sed 's/[[:space:]]*$//') + case " $CLONE_FETCH_REFSPECS " in + *refs/heads/*) ;; + *) + emit_section "Clone cannot fetch branches" "${LIPLUS_DIR} - remote.origin.fetch maps no branch. + configured: ${CLONE_FETCH_REFSPECS:-(none)} + expected: at least one refspec whose source side is under refs/heads/ +Tags still resolve, so fetch --tags succeeds and a bare git fetch origin is a +silent no-op; local branches never advance, so a worktree or a build taken from +a local branch is taken from a stale tree. +Surfacing is observation, not auto-action. The repair (one git config --add +remote.origin.fetch '+refs/heads/*:refs/remotes/origin/*' line) writes shared +local git state and is taken on a human go-sign; name this to the human and stop +there. Contract = rules/evolution/cold-start-synthesis.md Clone Branch Fetch +Surface." + CLONE_REFSPEC_EMITTED=1 + ;; + esac +fi + # =================================================================== # Diff-only emission (startup) # =================================================================== @@ -998,10 +1036,11 @@ while [ "$i" -lt "${#SECTION_KEYS[@]}" ]; do fi done -# The two date-driven surfaces count as material: pairing a just-emitted overdue -# entry or an expired tally cluster with "No new orientation material" would be -# self-contradictory output. -if [ "$EMITTED_ANY" -eq 0 ] && [ "$OBSERVATION_EMITTED" -eq 0 ] && [ "$TALLY_EMITTED" -eq 0 ] && [ "$FAIL_SAFE_FULL_EMIT" -eq 0 ]; then +# The three surfaces outside the diff-only set count as material: pairing a +# just-emitted overdue entry, an expired tally cluster or a clone that cannot +# fetch branches with "No new orientation material" would be self-contradictory +# output. +if [ "$EMITTED_ANY" -eq 0 ] && [ "$OBSERVATION_EMITTED" -eq 0 ] && [ "$TALLY_EMITTED" -eq 0 ] && [ "$CLONE_REFSPEC_EMITTED" -eq 0 ] && [ "$FAIL_SAFE_FULL_EMIT" -eq 0 ]; then emit_section "Orientation diff" "No new orientation material since last session. Prior in-context state remains authoritative." MARKER_EMITTED=1 fi diff --git a/docs/2.-Evolution.md b/docs/2.-Evolution.md index 0ca98ad4..1a1528e0 100644 --- a/docs/2.-Evolution.md +++ b/docs/2.-Evolution.md @@ -79,7 +79,7 @@ L2 Evolution layer はモデルレイヤーと同じ3種類の責務分類を使 Cold-start Synthesis だけは対話トリガー非依存のため skill 不適であり、フックで素材を stdout 出力してセッション冒頭のコンテキストに注入する。フックは素材を集めるだけで、合成判断は AI が Character_Instance で行う。 -Cold-start Synthesis の本体は `rules/evolution/cold-start-synthesis.md` に独立配置する。フックは同ファイルの preamble(frontmatter を除外し、最初の H2 節の手前まで)を anchor として抽出し素材として出力する。H2 節(Hook Emission Contract / Self-Evolution Observation Surface / Promotion Tally Expiry Surface)はフック自身の挙動仕様であり、AI がステップ 3 の適用時点で参照しないため anchor に含めない。ルールファイル自体は always-on でロードされるので、全文 emit は同一セッションの context に同じ本文を二重に載せていた。skill 経路は通らない。 +Cold-start Synthesis の本体は `rules/evolution/cold-start-synthesis.md` に独立配置する。フックは同ファイルの preamble(frontmatter を除外し、最初の H2 節の手前まで)を anchor として抽出し素材として出力する。H2 節(Hook Emission Contract / Self-Evolution Observation Surface / Promotion Tally Expiry Surface / Clone Branch Fetch Surface)はフック自身の挙動仕様であり、AI がステップ 3 の適用時点で参照しないため anchor に含めない。ルールファイル自体は always-on でロードされるので、全文 emit は同一セッションの context に同じ本文を二重に載せていた。skill 経路は通らない。 モデルレイヤーの Loop Safety、受容済み論点の扱い、レビュー出力の分離はモデルレイヤー側に残す。これらはランタイム不変条件であり、進化レイヤーは自己更新のために観測するが再定義しない。 @@ -197,14 +197,16 @@ L1 は種である。種は最も動かしにくくなければならない。 |------|------|------| | full emit | 初回セッション、fail-safe(state 欠落・破損・`sha256sum`/`node` 不在)、全 section 変化 | 全 section + footer に理由 | | diff-only emit | 一部 section だけ変化 | 変化した section のみ + cold-start rule anchor + `last_emit_at` の読み戻し 1 行(前回 baseline が消費された時刻。この状態のみ。識別子は持たない。stamp が欠落・不正形式なら省く) | -| no-new-material marker | 全 section 不変、かつ日付駆動の 2 surface(observation surface / promotion tally expiry surface)がいずれも emit されていない | "No new orientation material since last session" 1 行 + cold-start rule anchor | +| no-new-material marker | 全 section 不変、かつ diff-only の外の 3 surface(observation surface / promotion tally expiry surface / clone branch fetch surface)がいずれも emit されていない | "No new orientation material since last session" 1 行 + cold-start rule anchor | silent skip ではなく marker を出すのは、session boundary が発生した事実を human が観察可能なまま保つため。 -observation surface(`memory/self-evolution-observation.md` の due / overdue エントリ)または promotion tally expiry surface(`memory/promotion_tally.md` の窓が閉じた cluster)が emit された session では marker を出さない。どちらも section key を持たないため diff-only 比較には載らないが、素材としては marker を抑止する側に数える ── overdue な項目を提示しながら「新規素材なし」と述べるのは自己矛盾の出力になるため。 +observation surface(`memory/self-evolution-observation.md` の due / overdue エントリ)、promotion tally expiry surface(`memory/promotion_tally.md` の窓が閉じた cluster)、clone branch fetch surface(branch を引けない LI_PLUS_REPO clone)のいずれかが emit された session では marker を出さない。3 面とも section key を持たないため diff-only 比較には載らないが、素材としては marker を抑止する側に数える ── overdue な項目や壊れた clone を提示しながら「新規素材なし」と述べるのは自己矛盾の出力になるため。 **promotion tally expiry surface:** cluster tally(`memory/promotion_tally.md`)の `expires` が今日以前に達した cluster を cold-start で surface する。`expires < today` は overdue として出し、両条件が成立する場合は overdue のみ(observation surface と同じ扱い。1 項目を 2 軸で出すのはノイズ)。tally 書式は verdict 欄を持たないため状態は読まない ── 閾値判定のどの帰結も cluster を消すので、書かれたまま残っている cluster は判定が未了であることそのものである。したがって判定が下るまで毎 session 再 surface され、判定を取らなかった session が契機を失わない。surfacing は観測であって自動実行ではなく、**閾値判定の actor は surface された session を持つ agent、発火モーメントはその surfacing である**(#1894。従来は閾値の判定内容だけが規定され、tally を開く契機が想起依存に残っていた)。判定そのもの(起票 / 既存の `promotion` marker issue への合流 / 削除)と、起票前に既存 issue との突き合わせを置く規定は `rules/evolution/promotion-judgment.md` の Threshold Rules を正本とし、閾値の数値は本節に写さない。 +**clone branch fetch surface:** LI_PLUS_REPO clone の `remote.origin.fetch` が source 側を `refs/heads/` 以下に持つ refspec を 1 本も持たない状態を cold-start で surface する。そうした clone でも tag は解決するため `fetch --tags` は成功し、素の `git fetch origin` もエラーではなく no-op として成功する ── 何も落ちないまま branch の遅れだけが積み上がるので、surface が要る。述語は wildcard の literal 一致ではない(`--single-branch` の clone は branch を追えており該当しない)。**この面は既存 2 面と同じ surface class に置くが、trigger の種類は 3 つ目である** ── 既存 2 面は日付駆動、この面は state 駆動であり、借りるのは class の性質(section key 無し / diff-only 対象外 / 条件が成立する間は毎 session 再 surface / no-new-material marker を抑止)だけで、日付の語彙は借りない。**そして state 駆動は lifecycle を持たない。** 日付駆動の 2 面はどちらも明示的な除去を要する(observation は verdict を入れてエントリ削除、tally は閾値判定を経て削除)が、この面は条件が成立しなくなった瞬間に出なくなるので `verdict_state` も `expires` も除去手順も要らない。**この不在は欠落ではなく設計であり**、lifecycle を足すと「条件が消えたのに残るエントリ」という逆向きの実害が生まれる。actor は surface された session を持つ agent、発火モーメントはその surfacing であり、その場ですることは人への報告に限られる ── 修理は共有されたローカル git state の書き換えであり人の go-sign で行う(`rules/evolution/memory-entry-format.md` Artifact deletion calibration が `local non-git config / state` を caution `high` に置く)。`LI_PLUS_UPDATE_STATUS` には載せない(同 marker は adapter startup 手順 step 2 の起動条件であり reason で分岐しないため、検出のみの検査が更新手続きを起動する経路を持ってしまう)。動作契約の正本は `rules/evolution/cold-start-synthesis.md` の Clone Branch Fetch Surface 節(#1911)。 + **`resume` / `clear` / `compact` matcher の挙動:** 作業 context は連続のため diff-only 評価は行わず、cold-start rule anchor だけを再出力する。state file は更新しない。 state file は `{workspace_root}/.claude/state/last-cold-start-emit.json`(sha256 fingerprint を section ごとに永続化)。詳細仕様と schema は [6. Adapter — on-session-start.sh](6.-Adapter#on-session-startsh) を参照する。 diff --git a/docs/6.-Adapter.md b/docs/6.-Adapter.md index 63b2b074..16ebc1f0 100644 --- a/docs/6.-Adapter.md +++ b/docs/6.-Adapter.md @@ -79,14 +79,15 @@ settings.json の hook command はプロジェクトディレクトリにスペ - codex port(`adapter/codex/hooks/on-session-start.sh` / `adapter/codex/hooks/on-session-start.ps1`)は射程外。ホスト側に `~/.claude/projects//memory` という per-slug レイアウトが存在せず、両 port とも workspace-local な `memory/` しか見ないため、構造的にこの欠陥に到達しない。この命題の検証面は claude 側 hook の内側ではなく codex 側の2ファイルにあり、`.claude/projects` で両ファイルを走査すれば足りる — 該当はレイアウト不在を述べたコメント各1行のみで、cross-slug glob は存在しない。parity を理由に同形の変更を持ち込まないこと - self-evolution observation surface(`memory/self-evolution-observation.md` の check window が開いたエントリ):`verdict_state: pending` のうち `next_check <= today` を `DUE`、`expires < today` を `OVERDUE (human judgment needed)` として列挙する。ファイル解決は promotion candidates と同じ `MEMORY_DIR` 経路を再利用し、ファイル不在・該当エントリ無しは silent skip。**section key を持たない = diff-only 比較対象外**(理由は下記「Diff-only 出力」を参照)。動作契約の正本は `rules/evolution/cold-start-synthesis.md` の Self-Evolution Observation Surface 節 - promotion tally expiry surface(`memory/promotion_tally.md` の 3d 窓が閉じた cluster):`expires < today` を `OVERDUE (threshold judgment not taken)`、`expires <= today` を `DUE` として列挙し、行末に occurrence 件数を併記する(どの Threshold Rules 行が当たるかを選ぶのは件数のため)。tally 書式は verdict 欄を持たないので状態は読まない — 閾値判定のどの帰結も cluster を消すため、書かれたまま残っている cluster は判定が未了であることそのものである。ファイル解決は observation surface と同じ `MEMORY_DIR` 経路を再利用し、ファイル不在・該当 cluster 無しは silent skip。**section key を持たない = diff-only 比較対象外**(理由は下記「Diff-only 出力」を参照)。動作契約の正本は `rules/evolution/cold-start-synthesis.md` の Promotion Tally Expiry Surface 節、判定側の正本は `rules/evolution/promotion-judgment.md` の Threshold Rules 節 +- clone branch fetch surface(LI_PLUS_REPO clone の `remote.origin.fetch` が branch を 1 本も引けない状態):source 側を `refs/heads/` 以下に持つ refspec が 1 本も無い場合に、設定済み refspec と期待する形を併記して列挙する。述語は wildcard の literal 一致ではない(`--single-branch` の clone は branch を追えており該当しない)。`.git` を持たないディレクトリ(api mode)と `git` 不在は silent skip。**section key を持たない = diff-only 比較対象外**(理由は下記「Diff-only 出力」を参照)。既存 2 面と違い trigger は日付駆動ではなく **state 駆動**であり、lifecycle を持たない —— 条件が成立しなくなった時点で出なくなるため、除去のための判定を要しない。`LI_PLUS_UPDATE_STATUS` には載せない(同 marker は adapter startup 手順 step 2 の起動条件であり reason で分岐しないため、検出のみの検査が更新手続きを起動する経路を持ってしまう)。動作契約の正本は `rules/evolution/cold-start-synthesis.md` の Clone Branch Fetch Surface 節 #### 起動時ステータスマーカー(Li+ update status / Li+config: unrecognized value / Li+ language contract / gh install) cold-start 素材収集より前に、hook は `register_section` を経由しない printf 直接出力で最大4つのマーカーを emit する(`Li+config: unrecognized value` は該当時のみ)。いずれも diff-only 比較(sha256 fingerprint 方式)の対象外である。ただし各マーカーが matcher(`startup`/`resume`/`clear`/`compact`/`fork`)に関わらず毎回再 emit されるのは、`liplus-language` リポジトリ解決済み(bootstrap 後)の通常セッションに限る(Codex 版の `Li+ update status` のみ startup 限定。各マーカーの項を参照)。bootstrap 前の経路は挙動が異なるため、下記「pre-bootstrap 早期終了」を参照。 -- **`━━━ Li+ update status ━━━`**:`LI_PLUS_UPDATE_STATUS=unnecessary tag= channel=` または `LI_PLUS_UPDATE_STATUS=needed reason=` を出力する。adapter sentinel tag / Li+config schema / workspace 言語契約 / clone の branch fetch 可否の4軸 verify 結果であり、意味論(unnecessary/needed の分岐条件と reason 軸)の正本は [C. 更新同期手続き](C.-Update) を参照する。AI 側の消費契約(Li+update.md walkthrough を skip するか実行するか)は `adapter/claude/CLAUDE.md` の "Execute the following at startup" ブロックが担い、本書では再掲しない。 +- **`━━━ Li+ update status ━━━`**:`LI_PLUS_UPDATE_STATUS=unnecessary tag= channel=` または `LI_PLUS_UPDATE_STATUS=needed reason=` を出力する。adapter sentinel tag / Li+config schema / workspace 言語契約の3軸 verify 結果であり、意味論(unnecessary/needed の分岐条件と reason 軸)の正本は [C. 更新同期手続き](C.-Update) を参照する。AI 側の消費契約(Li+update.md walkthrough を skip するか実行するか)は `adapter/claude/CLAUDE.md` の "Execute the following at startup" ブロックが担い、本書では再掲しない。 - **`━━━ Li+config: unrecognized value ━━━`**:`LI_PLUS_CHANNEL` の値が既知の 3 値(`latest` / `release` / `tag`)のいずれでもなく、かつ空でもない場合のみ出力する。キー名と実際の値を含む 1 行を出すだけであり、既定へ倒れる挙動そのものは変えない(対象タグは未解決のまま、update status は `needed`)。空値は未設定であり既定が正しいため対象外。update status と同じ startup 限定(Codex 版)/全 matcher(Claude 版)の分岐に従う。正規化(`Latest` → `latest`)を採らずに値を名指しする理由は、正規化では `lattest` のような打ち間違いが依然として黙って既定へ倒れるため、「黙って倒れる」クラス全体を塞げないことによる(issue #1804)。 -- **`━━━ Li+ language contract ━━━`**:`LI_PLUS_BASE_LANGUAGE=<値>` / `LI_PLUS_PROJECT_LANGUAGE=<値>` を出力する。値は上記4軸 verify の軸3が Li+config.md から抽出したものをそのまま使う(毎セッション実行時に live な config から取得しており、生成ファイルへの焼き込みではない)。未解決の値は `unset` として出力し、ブロック自体は(下記 pre-bootstrap 早期終了に到達しない限り)無条件に emit する — bootstrap 済みセッションの内側では「ブロックが無い」状態と「値が無い」状態を AI 側が区別せずに済むため。この marker が在る理由は、契約本文(`Workspace_Language_Contract`)が常時 context に在るのに対し、その**値**の解決だけが「Li+config.md を読む」という手続きとして書かれており、同ファイルはどのエージェントの context にも自動ロードされないため(issue #1575)。契約の意味論(base / project の定義と precedence)の正本は `adapter/*/CLAUDE.md`・`AGENTS.md` の `Workspace_Language_Contract` であり、本 marker は値のみを運ぶ。Codex 版では update status が startup matcher 限定なのに対し、本 marker は Claude 版と同じく全 matcher で emit する(rules 再注入と同じ理由で、非 startup の matcher でも値が context に要る)。 +- **`━━━ Li+ language contract ━━━`**:`LI_PLUS_BASE_LANGUAGE=<値>` / `LI_PLUS_PROJECT_LANGUAGE=<値>` を出力する。値は上記3軸 verify の軸3が Li+config.md から抽出したものをそのまま使う(毎セッション実行時に live な config から取得しており、生成ファイルへの焼き込みではない)。未解決の値は `unset` として出力し、ブロック自体は(下記 pre-bootstrap 早期終了に到達しない限り)無条件に emit する — bootstrap 済みセッションの内側では「ブロックが無い」状態と「値が無い」状態を AI 側が区別せずに済むため。この marker が在る理由は、契約本文(`Workspace_Language_Contract`)が常時 context に在るのに対し、その**値**の解決だけが「Li+config.md を読む」という手続きとして書かれており、同ファイルはどのエージェントの context にも自動ロードされないため(issue #1575)。契約の意味論(base / project の定義と precedence)の正本は `adapter/*/CLAUDE.md`・`AGENTS.md` の `Workspace_Language_Contract` であり、本 marker は値のみを運ぶ。Codex 版では update status が startup matcher 限定なのに対し、本 marker は Claude 版と同じく全 matcher で emit する(rules 再注入と同じ理由で、非 startup の matcher でも値が context に要る)。 - **`━━━ gh install ━━━`**:`gh` CLI が PATH 上に無い場合のみ出力する(`command -v gh` が真を返せばブロック自体が実行されず、マーカーも出ない)。ブートストラップ済み通常セッションに限定すると、ブロックに入った場合の結果は `GH_INSTALL_STATUS=installed` / `failed: <末尾ログ抜粋>` / `missing: <ホスト別ガイダンス>` のいずれかであり、この3値すべてでマーカーは出力される(`installed` も無言スキップではない)。pre-bootstrap 経路では出力条件がこれと異なる(下記「pre-bootstrap 早期終了」参照。zero-output ケースはそちらにもう1つ存在する)。Linux ホストはアーキテクチャ判定つきで `~/.local/bin/gh` へ自動インストールを試み、成否を `installed`/`failed` として報告する。macOS と Windows(Git-Bash・MSYS2・Cygwin) はドキュメント化された前提条件として扱い、自動インストールせず具体的な導入コマンド(`brew install gh` / `winget install --id GitHub.cli`)を案内する。認識できないホストカーネル(default ブランチ)は、具体的なコマンドは示さず「プラットフォームのパッケージマネージャーで導入せよ」という一般的な案内のみを出す。 pre-bootstrap 早期終了:`liplus-language` リポジトリが未解決(bootstrap 前)の段階では、hook は gh install チェックの直後、matcher 解決や更新同期 verify に入る前に exit する。このガードは `GH_INSTALL_STATUS` が `failed` または `missing` で始まる場合のみマーカーを出力する。したがって、この経路では `gh` が既に PATH 上にある場合に加え、Linux 自動インストールが成功した場合(`GH_INSTALL_STATUS=installed`)も無出力のまま hook が終了する — 「ブートストラップ済み通常セッションでは installed も出力される」の対象外にあたる、もう1つの zero-output ケースである。Li+ update status マーカー、Li+config: unrecognized value マーカー、Li+ language contract マーカーはこの経路では一切出力されない。したがって bootstrap 前のセッションでは言語値が context に届かない — この状態の扱い(marker 不在も `unset` と同じく human へ確認する)は `adapter/*/CLAUDE.md`・`AGENTS.md` の `Workspace_Language_Contract` が持つ。 @@ -117,7 +118,7 @@ matcher 別の挙動: | matcher | 挙動 | |---------|------| -| `startup` | 各 section の fingerprint を前回値と比較。変化あった section のみ emit。全 section 不変なら "No new orientation material since last session" marker を 1 行出力(silent skip ではなく、session boundary 観察可能性を保つ)。ただし日付駆動の 2 surface(observation surface / promotion tally expiry surface)のいずれかが emit された session では marker を出さない — overdue を提示しながら「新規素材なし」と述べるのは自己矛盾のため。加えて、state file の `last_emit_at` を 1 行読み戻して emit する(前回 baseline が消費された時刻。diff-only 状態のみ —— full emit は何も抑制しておらず、marker 状態は既に境界を 1 行で示している)。識別子は持たず、追加もしない —— 「いつ baseline が動いたか」だけを述べ、「誰が動かしたか」は述べない(共有 workspace の他セッションによる消費と自セッションの開き直しはここでは同じに読める)。stamp が欠落・不正形式の場合はこの行を省くだけで、fail-safe の理由にはしない | +| `startup` | 各 section の fingerprint を前回値と比較。変化あった section のみ emit。全 section 不変なら "No new orientation material since last session" marker を 1 行出力(silent skip ではなく、session boundary 観察可能性を保つ)。ただし diff-only の外に置かれた 3 surface(observation surface / promotion tally expiry surface / clone branch fetch surface)のいずれかが emit された session では marker を出さない — overdue な項目や壊れた clone を提示しながら「新規素材なし」と述べるのは自己矛盾のため。加えて、state file の `last_emit_at` を 1 行読み戻して emit する(前回 baseline が消費された時刻。diff-only 状態のみ —— full emit は何も抑制しておらず、marker 状態は既に境界を 1 行で示している)。識別子は持たず、追加もしない —— 「いつ baseline が動いたか」だけを述べ、「誰が動かしたか」は述べない(共有 workspace の他セッションによる消費と自セッションの開き直しはここでは同じに読める)。stamp が欠落・不正形式の場合はこの行を省くだけで、fail-safe の理由にはしない | | `resume` / `clear` / `compact` / `fork` | 作業 context は連続のため diff-only 評価は行わず、cold-start rule anchor だけを再出力。state file は更新しない | fail-safe 動作(startup matcher 時):以下のいずれかが発生した場合は全 section を full emit し、instruction footer に理由を human observable な形で記載する: @@ -131,7 +132,7 @@ matcher 解決:stdin の JSON(Claude Code から渡される hook payload) cold-start rule anchor の常時 emit は drift recovery anchor としての役割を担うため、diff-only 比較セットから明示的に外す。詳細は `rules/evolution/cold-start-synthesis.md` および [2. Evolution](2.-Evolution#cold-start-synthesisセッション開始時の状態合成) を参照する。 -self-evolution observation surface と promotion tally expiry surface も同じく比較セット外だが、外す理由は別軸である(2 つは同形であり、以下は両方に等しく当たる)。こちらは**トリガーが日付駆動なのに body が内容駆動**という非対称に由来する。未解決のまま日をまたいだエントリは body が byte 一致のままなので、fingerprint 比較に載せると「最初の 1 セッションだけ表面化し、以後は注意を要する期間ずっと抑制される」という意図と正反対の挙動になる。該当エントリが無ければ body が空になり silent skip されるため、常時 emit にしても通常セッションの context コストはゼロ。`expires` を過ぎたエントリは `OVERDUE` としてのみ報告する(`next_check` も過去であるのが通常だが、同一エントリを両軸で二重に出すのはノイズであり、escalation を担うのは overdue 軸)。 tally 側も同じく、窓を過ぎた cluster は `OVERDUE` としてのみ報告する。 +self-evolution observation surface と promotion tally expiry surface も同じく比較セット外だが、外す理由は別軸である(2 つは同形であり、以下は両方に等しく当たる)。こちらは**トリガーが日付駆動なのに body が内容駆動**という非対称に由来する。未解決のまま日をまたいだエントリは body が byte 一致のままなので、fingerprint 比較に載せると「最初の 1 セッションだけ表面化し、以後は注意を要する期間ずっと抑制される」という意図と正反対の挙動になる。該当エントリが無ければ body が空になり silent skip されるため、常時 emit にしても通常セッションの context コストはゼロ。`expires` を過ぎたエントリは `OVERDUE` としてのみ報告する(`next_check` も過去であるのが通常だが、同一エントリを両軸で二重に出すのはノイズであり、escalation を担うのは overdue 軸)。 tally 側も同じく、窓を過ぎた cluster は `OVERDUE` としてのみ報告する。clone branch fetch surface も比較セット外だが、非対称の向きが違う —— トリガーが **state 駆動**であり、条件が成立し続ける間は body が byte 一致のままなので、fingerprint 比較に載せると壊れている間ずっと黙る。日付を読まないため due / overdue の区別も持たず、条件が成立しなくなった時点で body が空になり silent skip される。 ### on-user-prompt.sh diff --git a/docs/C.-Update.md b/docs/C.-Update.md index ef5ea256..85767c98 100644 --- a/docs/C.-Update.md +++ b/docs/C.-Update.md @@ -1,7 +1,7 @@ # 更新同期手続き仕様書 本文書は Li+ のアダプター / 設定の更新同期手続き(`Li+update.md`)の仕様を定義する。 -Li+config.md の設定値を前提とし、アダプター sentinel tag・Li+config schema・workspace 言語契約のいずれかが目標状態から逸脱した時、または clone モードの clone が branch を fetch できない状態にある時に AI が実行する Phase を記述する。 +Li+config.md の設定値を前提とし、アダプター sentinel tag・Li+config schema・workspace 言語契約のいずれかが目標状態から逸脱した時に AI が実行する Phase を記述する。 --- @@ -9,7 +9,7 @@ Li+config.md の設定値を前提とし、アダプター sentinel tag・Li+con 更新同期手続きは **`Li+update.md`** に定義されている。Li+config.md はユーザー設定のみを保持し、同期ロジックは分離されている。 -`on-session-start.sh` hook が 4 軸(adapter sentinel tag / Li+config schema / 言語契約 / clone の branch fetch 可否)を verify し、いずれかが drift していれば `LI_PLUS_UPDATE_STATUS=needed` を emit する。AI はこの marker を見て本手続きを実行するか判定する。大半のセッションでは `LI_PLUS_UPDATE_STATUS=unnecessary` となり、本手続きは走らない(旧称「セッション起動フロー」が現運用とずれていたため、v1.17.10 で「更新同期手続き」へ rename した)。 +`on-session-start.sh` hook が 3 軸(adapter sentinel tag / Li+config schema / 言語契約)を verify し、いずれかが drift していれば `LI_PLUS_UPDATE_STATUS=needed` を emit する。AI はこの marker を見て本手続きを実行するか判定する。大半のセッションでは `LI_PLUS_UPDATE_STATUS=unnecessary` となり、本手続きは走らない(旧称「セッション起動フロー」が現運用とずれていたため、v1.17.10 で「更新同期手続き」へ rename した)。 AI は Li+config.md を読み込んだ後、`Li+update.md` の Phase 1 から Phase 6 を順に実行する。各 Phase は直前までの Phase を依存前提として宣言する。認証情報をチャットに出力してはいけない。 @@ -104,7 +104,7 @@ host OS は adapter 種別(runtime=claude / runtime=codex)から推測しな `git -C {workspace_root}/{repo_dir} checkout {target_tag}` どちらも実行する literal そのものであり、フラグを追加しない - 存在する → `fetch --tags` を実行し: - a. clone が branch を fetch できるかを確認する。`git -C {workspace_root}/{repo_dir} config --get-all remote.origin.fetch` が、source 側を `refs/heads/` 以下に持つ refspec を最低 1 本保持していること。1 本も無い clone でも tag は解決するため、直前の `fetch --tags` は成功したまま branch はどれも動かず、後続の素の `git fetch origin` もエラーではなく no-op として成功する。無い場合は人間にその事実を名指し、代償も名指す(ローカル branch が永久に進まないため、ローカル branch から生やした worktree やビルドは古い木から取られる)。**検出のみ**であり、refspec の追加も re-clone も行わず、中断もしない(b へ続行する)。修理は人間の側にある(共有されたローカル git state を書き換えるため、エージェントが独断で踏まない)。同じ検査は on-session-start hook の 3 port が verify 軸 4 として毎セッション実行しており、それが `LI_PLUS_UPDATE_STATUS` marker の `clone-refspec-no-branch-mapping` reason になる。clone がその状態に留まる間、marker は毎セッション `needed` を報告し本手続きが再走する(この持続は escalation であり、本手続きが解消すべき drift ではない) + a. clone が branch を fetch できるかを確認する。`git -C {workspace_root}/{repo_dir} config --get-all remote.origin.fetch` が、source 側を `refs/heads/` 以下に持つ refspec を最低 1 本保持していること。1 本も無い clone でも tag は解決するため、直前の `fetch --tags` は成功したまま branch はどれも動かず、後続の素の `git fetch origin` もエラーではなく no-op として成功する。無い場合は人間にその事実を名指し、代償も名指す(ローカル branch が永久に進まないため、ローカル branch から生やした worktree やビルドは古い木から取られる)。**検出のみ**であり、refspec の追加も re-clone も行わず、中断もしない(b へ続行する)。修理は人間の側にある(共有されたローカル git state を書き換えるため、エージェントが独断で踏まない)。同じ条件は on-session-start hook が毎セッション surface しており、その状態に留まる clone はそこで報告され続ける。載せ先と、それが `LI_PLUS_UPDATE_STATUS` でない理由は `rules/evolution/cold-start-synthesis.md` の Clone Branch Fetch Surface 節 b. 現在 checkout 中のタグと、`LI_PLUS_CHANNEL` から解決した対象タグを両方確認して報告する。その際、どちらが新しいかを名指す。channel によっては対象タグが現在タグより古いことがあり、対象であることから新しさは導けない c. 一致する場合はそのまま続行 d. 不一致の場合、Phase 4 へ進む前に人間にどうするか確認する。この選択が解決するまで bootstrap 完了扱いにしない。最小選択肢は「対象タグへ更新してから続行」「今セッションは現在タグのまま続行」 diff --git a/rules/evolution/cold-start-synthesis.md b/rules/evolution/cold-start-synthesis.md index 2bd7bbcf..595baad5 100644 --- a/rules/evolution/cold-start-synthesis.md +++ b/rules/evolution/cold-start-synthesis.md @@ -37,12 +37,12 @@ The hook's own behavior. Read on demand; not applied at the step 3 moment. Anchor cut: the hook re-anchors the preamble above (H1 body up to the first H2 section), not the whole file. This file is always-on loaded, so a full re-emit would put the same text in one session's context twice; the preamble is the part the AI applies at the step 3 moment, and the H2 sections below are not. A file with no H2 section is emitted whole — the cut is an economy, and losing the anchor is the worse failure. Hook coordination: -`on-session-start.sh` persists and surfaces at session open: decision structure index head, rules/ tree (fetch address table for cold-start-loaded rules cache), recent release tags, open in-progress issues, self-evaluation log head, promotion candidates, promotion tally clusters whose window has closed, cold-start rule anchor. The hook emits material in diff-only mode (matcher = startup): only sections whose body changed since the previous startup invocation are re-emitted. The cold-start rule anchor is always re-emitted regardless of diff state. +`on-session-start.sh` persists and surfaces at session open: decision structure index head, rules/ tree (fetch address table for cold-start-loaded rules cache), recent release tags, open in-progress issues, self-evaluation log head, promotion candidates, promotion tally clusters whose window has closed, a Li+ clone that cannot fetch branches, cold-start rule anchor. The hook emits material in diff-only mode (matcher = startup): only sections whose body changed since the previous startup invocation are re-emitted. The cold-start rule anchor is always re-emitted regardless of diff state. Hook emission states (matcher = startup): - full emit = first session after install, fail-safe (state missing / unreadable / sha256 unavailable / node unavailable), or every section changed. All sections shown. The four reasons are the bash port's set. The PowerShell port parses JSON natively so it has no node dependency, and it calls SHA256 unconditionally with no availability guard, so neither of those two reasons can fire there: its fail-safe set is the two state-file reasons alone. - diff-only = some sections changed since prior session. Only changed sections shown, plus a one-line read-back of the state file's `last_emit_at` — when the prior baseline was consumed. Emitted in this state only: full emit suppressed nothing, and the marker state already states the boundary in one line. The line carries no identifier and none is added: it says when the baseline moved, not who moved it. An absent or malformed stamp drops the line, and is not a fail-safe reason. -- no-new-material marker = no section changed AND neither date-driven surface below emitted anything. A single "No new orientation material since last session" line is emitted (silent skip is intentionally avoided so the human can still observe the session boundary). A surfaced self-evolution observation entry (see Self-Evolution Observation Surface below) and a surfaced promotion tally cluster (see Promotion Tally Expiry Surface below) each count as material even though neither carries a section key, so the marker is suppressed for that session; pairing an overdue item with "no new material" would be self-contradictory output. +- no-new-material marker = no section changed AND no surface below emitted anything. A single "No new orientation material since last session" line is emitted (silent skip is intentionally avoided so the human can still observe the session boundary). A surfaced self-evolution observation entry (see Self-Evolution Observation Surface below), a surfaced promotion tally cluster (see Promotion Tally Expiry Surface below) and a surfaced clone that cannot fetch branches (see Clone Branch Fetch Surface below) each count as material even though none of them carries a section key, so the marker is suppressed for that session; pairing any of them with "no new material" would be self-contradictory output. Hook emission states (matcher = resume / clear / compact / fork): - Only the cold-start rule anchor is re-emitted. The work context is continuous; the diff-only set is not re-evaluated, and the state file is not updated. @@ -93,4 +93,33 @@ Silent skip when the tally file is absent or no cluster has reached its window. + + +## Clone Branch Fetch Surface + +The Li+ clone's configured fetch refspecs are surfaced at cold-start when none of them can move a branch. + +Surface target: +- the workspace holds a Li+ clone, and `remote.origin.fetch` carries no refspec whose source side is under `refs/heads/` -> surface as "clone cannot fetch branches" + +The predicate is the source side of a refspec, not the wildcard literal. A clone made with `--single-branch` carries `+refs/heads/:refs/remotes/origin/`; it does move that branch and is not this condition, so matching on `refs/heads/*` reports it every session. + +What such a clone does instead of failing is what makes it need a surface: tags still resolve, so a `fetch --tags` succeeds, a bare `git fetch origin` succeeds as a no-op, and no branch moves. Nothing raises, and the update-status axes read tags, which are the refs that do advance. + +Third trigger kind on this surface class. The two surfaces above are date-driven — a window opens and the entry comes due. This one is state-driven: the condition either holds this session or it does not. What is borrowed from the class is its properties — no section key, outside the diff-only set, re-surfaced every session while it holds, and counting as material against the no-new-material marker — and not its date vocabulary. Nothing here reads a date, and no window opens or closes. + +No lifecycle, and the absence is deliberate. The two date-driven surfaces each require an explicit removal: an observation entry is deleted on a `settle` / `revert` / `supersede` verdict, a tally cluster on the threshold judgment, and until that judgment is taken the entry stands. A state-driven surface needs none of it — the emission stops the moment the condition stops holding, which is the same moment the repair lands. So there is no `verdict_state`, no `expires`, and no removal step, and adding any of them would reintroduce exactly what they are absent to prevent: an entry left standing after the condition it reports has cleared. Read the absence as the design; do not fill it. + +Not carried on `LI_PLUS_UPDATE_STATUS`. That marker is the trigger condition for step 2 of the adapter startup procedure (`adapter/claude/CLAUDE.md` / `adapter/codex/AGENTS.md`), which branches on the status alone and never on a reason, so any reason placed there starts the update walkthrough. A detection-only check must not have that path, and being able to append a reason string is not evidence of sharing an axis with the ones already there. + +Actor = the agent holding the session the surface fires in. Firing moment = that surfacing. What the moment calls for is naming the condition to the human, and nothing further: the repair writes shared local git state — `local non-git config / state (gitignored, meaningful)`, caution `high` in `rules/evolution/memory-entry-format.md` Artifact deletion calibration — so it is taken on a human go-sign, and no agent takes it. The Operational criterion's `hook-surfaced items = silent` does not silence this one: what that line withholds is a re-report of material the human already holds, and the only actor who can act here is the human, so a session that says nothing leaves the surface with no reader. + +Surfacing is observation, not auto-action. + +Material gathering and concrete surfacing logic belong to the adapter cold-start path, as with the two surfaces above. This section defines only the behavior contract. + +Silent skip when the workspace holds no clone (api mode) or `git` is unavailable. Neither state is evidence about a refspec, and reporting one as though it were would put a finding on a workspace that has nothing to repair. + + + diff --git a/tests/test_clone_refspec_branch_check.py b/tests/test_clone_refspec_branch_check.py index 86127339..b642970f 100644 --- a/tests/test_clone_refspec_branch_check.py +++ b/tests/test_clone_refspec_branch_check.py @@ -1,31 +1,44 @@ -"""Behavioural coverage for the clone-mode branch-fetch check. +"""Behavioural coverage for the cold-start clone branch-fetch surface. Target = the three `adapter/*/hooks/on-session-start.*` implementations -(claude bash / codex bash / codex PowerShell) and the two prose surfaces that -carry the same decision. Issue #1911. +(claude bash / codex bash / codex PowerShell) and the prose surfaces carrying +the same decision. Issue #1911. The defect this pins: `remote.origin.fetch` decides which refs a fetch moves, and a clone configured with tag mappings only still resolves tags. So `fetch --tags` on Li+update.md's clone-mode `exists` path succeeds, a later bare -`git fetch origin` succeeds as a no-op, and the sentinel-tag axis -- which reads -tags -- keeps emitting `unnecessary` while every branch stays where it was. One -such clone sat 374 commits behind for close to five months, and it surfaced by -accident rather than through any check. +`git fetch origin` succeeds as a no-op, and nothing raises while every branch +stays where it was. One such clone sat 374 commits behind for close to five +months, and it surfaced by accident rather than through any check. What is pinned -------------- -Detection, and only detection: the axis reports -`clone-refspec-no-branch-mapping` on the `LI_PLUS_UPDATE_STATUS` reason surface -and repairs nothing, so the assertions read the reason out of the marker and -also assert the fixture's refspec configuration is unchanged by the run. The -predicate is "at least one refspec whose source side is under `refs/heads/`", -so a single-branch clone passes -- it does track a branch. Two states are -silent because neither is evidence about a refspec: a directory that is not a -clone (api mode), and a host without `git`. - -The reason token is asserted across all five surfaces because a port left -behind makes the same workspace report differently depending on which host -adapter ran, which is the shape #1804 produced once already. +The reporting destination as much as the detection. The finding goes to the +cold-start surface class that carries no section key and sits outside the +diff-only set, alongside the observation and tally surfaces, and it is +explicitly NOT stacked on `LI_PLUS_UPDATE_STATUS` -- that marker is the trigger +condition for step 2 of the adapter startup procedure and branches on the status +alone, so a reason there would let a detection-only check start the update +walkthrough. Both directions are asserted: the surface fires, and the marker +stays clean. + +Detection, and only detection: the run must leave the fixture's refspec +configuration untouched. The predicate is "at least one refspec whose source +side is under `refs/heads/`", so a single-branch clone passes -- it does track a +branch. Two states are silent because neither is evidence about a refspec: a +directory that is not a clone (api mode), and a host without `git`. + +Being outside the diff-only set is what a state-driven trigger needs, and it is +asserted on a second run against the same workspace: a fingerprinted section +would surface the clone once and then go silent for exactly as long as the +defect persisted. The same run pins the no-new-material marker suppression, +since a session pairing a broken clone with "no new orientation material" would +contradict itself. + +A port left behind makes the same workspace report differently depending on +which host adapter ran, which is the shape #1804 produced once already, so the +surface is asserted on all three ports and the decision's prose surfaces are +asserted to name it. """ from __future__ import annotations @@ -38,6 +51,8 @@ from test_on_session_start_observation_surface import ( ADAPTERS, Workspace, + emitted_sections, + no_new_material_marker, require_runtime, ) from test_config_value_parity import update_status_line @@ -45,19 +60,33 @@ ROOT = Path(__file__).resolve().parents[1] -REASON = "clone-refspec-no-branch-mapping" +# The surface is located by topic, not by banner text: the banner is an adapter +# choice (`rules/evolution/cold-start-synthesis.md` delegates presentation), and +# pinning it would make every assertion here depend on one string. +SURFACE_TOPIC = "clone" + +# What the emitted body must carry to be actionable: the key that is misconfigured +# and the ref namespace no refspec sources. +BODY_TOKENS = ("remote.origin.fetch", "refs/heads/") WILDCARD = "+refs/heads/*:refs/remotes/origin/*" SINGLE_BRANCH = "+refs/heads/main:refs/remotes/origin/main" TAG_ONLY = "+refs/tags/build-2026-04-12.8:refs/tags/build-2026-04-12.8" -# The three hook ports, plus the spec they implement and the doc that mirrors it. -SURFACES = ( +PORTS = ( "adapter/claude/hooks/on-session-start.sh", "adapter/codex/hooks/on-session-start.sh", "adapter/codex/hooks/on-session-start.ps1", +) + +# The behaviour contract, the spec implementing the walkthrough-side check, and +# the two docs that mirror them. +PROSE = ( + "rules/evolution/cold-start-synthesis.md", "Li+update.md", "docs/C.-Update.md", + "docs/6.-Adapter.md", + "docs/2.-Evolution.md", ) GIT = shutil.which("git") @@ -104,45 +133,89 @@ def configured_refspecs(directory: Path) -> list[str]: class CloneRefspecParityTestCase(unittest.TestCase): - """The reason token reaches every surface carrying this decision.""" + """The decision reaches every surface that carries it.""" - def test_every_surface_names_the_reason(self) -> None: - for surface in SURFACES: + def test_every_port_reads_the_fetch_refspec(self) -> None: + """A port that emits without reading the config is not a check.""" + for surface in PORTS: with self.subTest(surface=surface): text = (ROOT / surface).read_text(encoding="utf-8") - self.assertIn(REASON, text) + for token in BODY_TOKENS: + self.assertIn(token, text) - def test_every_port_reads_the_fetch_refspec(self) -> None: - """A port that emits the token without reading the config is not a check.""" - for surface in SURFACES[:3]: + def test_no_port_stacks_it_on_the_update_status_marker(self) -> None: + """The destination was moved off the marker; no port may put it back. + + Read off the source rather than only off an emission: the marker's + reason list is assembled from whatever axis blocks a port carries, so a + fourth one added later would read as an ordinary axis at review time. + """ + for surface in PORTS: + with self.subTest(surface=surface): + text = (ROOT / surface).read_text(encoding="utf-8") + for line in text.split("\n"): + if "refs/heads/" in line: + self.assertNotIn("UPDATEREASON", line.upper().replace("$", "").replace("_", "")) + + def test_prose_surfaces_name_the_surface(self) -> None: + for surface in PROSE: with self.subTest(surface=surface): text = (ROOT / surface).read_text(encoding="utf-8") - self.assertIn("remote.origin.fetch", text) - self.assertIn("refs/heads/", text) + self.assertIn("Clone Branch Fetch Surface", text) + + def test_contract_declares_the_absent_lifecycle(self) -> None: + """The lifecycle fields are absent by design, and it must say so. + + Without that, a later reader repairs the "gap" by adding one, and an + entry then stands after the condition it reports has cleared. + """ + text = (ROOT / "rules/evolution/cold-start-synthesis.md").read_text(encoding="utf-8") + section = text.split("## Clone Branch Fetch Surface", 1)[1] + section = section.split("", 1)[0] + self.assertIn("verdict_state", section) + self.assertIn("expires", section) + self.assertIn("state-driven", section) class CloneRefspecBranchCheckTestCase(unittest.TestCase): def setUp(self) -> None: if not GIT: require_runtime("git", "clone refspec branch check") - self.ws = Workspace() - self.addCleanup(self.ws.cleanup) + self.ws = self.new_workspace() + + def new_workspace(self) -> Workspace: + workspace = Workspace() + self.addCleanup(workspace.cleanup) # Past the codex ports' unresolved-source guard; without it those two - # hooks exit before any Li+ marker is emitted. - self.ws.seed_coldstart_rule("CLONE-REFSPEC-FIXTURE") + # hooks exit before emitting any material at all. + workspace.seed_coldstart_rule("CLONE-REFSPEC-FIXTURE") + return workspace - def reasons(self, adapter: str) -> str: - output = self.ws.run(adapter, "startup") + def output(self, adapter: str) -> str: + out = self.ws.run(adapter, "startup") self.ws.clear_state() - line = update_status_line(output) - self.assertIsNotNone(line, f"{adapter} emitted no update status marker") - return line + return out + + def surface(self, hook_output: str) -> str | None: + for banner, body in emitted_sections(hook_output): + if SURFACE_TOPIC in banner.lower(): + return body + return None def assertReported(self, adapter: str) -> None: - self.assertIn(REASON, self.reasons(adapter)) + out = self.output(adapter) + body = self.surface(out) + self.assertIsNotNone(body, f"{adapter} surfaced nothing for a clone with no branch mapping") + for token in BODY_TOKENS: + self.assertIn(token, body) + # The destination is the cold-start surface, not the update marker. + line = update_status_line(out) + if line is not None: + self.assertNotIn("refs/heads", line) + self.assertNotIn("refspec", line) def assertSilent(self, adapter: str) -> None: - self.assertNotIn(REASON, self.reasons(adapter)) + self.assertIsNone(self.surface(self.output(adapter))) def test_tag_only_refspec_is_reported(self) -> None: """The measured state: tags resolve, no branch mapping exists.""" @@ -164,14 +237,14 @@ def test_wildcard_refspec_is_silent(self) -> None: self.assertSilent(adapter) def test_single_branch_refspec_is_silent(self) -> None: - """A single-branch clone tracks a branch, so it is not this defect.""" + """A single-branch clone tracks a branch, so it is not this condition.""" make_clone(self.ws.liplus, SINGLE_BRANCH) for adapter in ADAPTERS: with self.subTest(adapter=adapter): self.assertSilent(adapter) def test_tags_alongside_a_branch_are_silent(self) -> None: - """The wildcard's presence decides, not the count of tag mappings.""" + """A branch mapping decides, not the count of tag mappings.""" make_clone(self.ws.liplus, TAG_ONLY, WILDCARD) for adapter in ADAPTERS: with self.subTest(adapter=adapter): @@ -188,9 +261,47 @@ def test_detection_does_not_repair(self) -> None: make_clone(self.ws.liplus, TAG_ONLY) for adapter in ADAPTERS: with self.subTest(adapter=adapter): - self.reasons(adapter) + self.output(adapter) self.assertEqual(configured_refspecs(self.ws.liplus), [TAG_ONLY]) + def test_state_driven_surface_survives_the_second_run(self) -> None: + """Still surfaced on a second startup, and it suppresses the marker. + + This is why the finding is not an ordinary diff-only section: the body + does not change while the defect persists, so a fingerprinted section + would report once and then stay silent for exactly as long as the clone + stayed broken. The second run is also where the no-new-material marker + would appear, and a session pairing it with a broken clone would + contradict itself. + """ + for adapter in ADAPTERS: + with self.subTest(adapter=adapter): + workspace = self.new_workspace() + make_clone(workspace.liplus, TAG_ONLY) + first = workspace.run(adapter, "startup") + self.assertIsNotNone(self.surface(first)) + second = workspace.run(adapter, "startup") + body = self.surface(second) + self.assertIsNotNone(body, f"{adapter} went silent on the second run") + for token in BODY_TOKENS: + self.assertIn(token, body) + self.assertIsNone( + no_new_material_marker(second), + f"{adapter} paired a surfaced clone with the no-new-material marker", + ) + + # Control: the same fixture with a healthy clone does reach the + # marker on its second run. Without it, the assertion above + # would also pass on a fixture that never got as far as + # emitting one, and would be reporting nothing. + control = self.new_workspace() + make_clone(control.liplus, WILDCARD) + control.run(adapter, "startup") + self.assertIsNotNone( + no_new_material_marker(control.run(adapter, "startup")), + f"{adapter} fixture never reaches the marker; the assertion above proves nothing", + ) + if __name__ == "__main__": unittest.main() From 7ac43c9853e7c406f4943beaf2b6f727c3070445 Mon Sep 17 00:00:00 2001 From: Yoshiharu Uematsu Date: Wed, 9 Sep 2026 02:49:06 +0900 Subject: [PATCH 4/4] fix(hooks): read the branch mapping off the refspec source side only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit brake 1 round 1(再設計後の baseline)の指摘を受理。Axis A 1 件 / Axis B 3 件 (同一実体、3 port)、fixed axis 3 件。 述語が refspec 全体に対する部分文字列一致になっており、`[+]:` の src 側限定を実装していなかった。`+refs/tags/v1:refs/heads/mirror` のように dst 側にしか `refs/heads/` が現れない構成は branch を 1 本も引かないのに 健全と読まれる。本 PR 自身が新設した contract literal(source side)に対する 不一致であり、3 port とも同じ近似のため port 間 parity の問題ではない。 - hook 3 port — refspec を 1 本ずつ読み、先頭 `+` を落として `:` の手前を source 側とし、`refs/heads/` 以下かを判定する。`^` は exclusion で mapping を成立させないため除外。ps1 は Ordinal 比較(ref 名は case-sensitive) - `rules/evolution/cold-start-synthesis.md` / `docs/6.-Adapter.md` — src 側の 読み方と exclusion の扱いを本文に明記(実装と literal を一致させる) - tests — dst 側にのみ `refs/heads/` を持つ fixture と exclusion 単独の fixture を 追加し、いずれも報告されることを pin。複合 fixture もこの 2 本を含める形に更新 - hook 3 port のコメントから括弧書きの issue 番号を除去(fixed axis 3 件)。 除去しても canonical contract へのパス参照が残り behavior semantic は変わらない #1911 Co-Authored-By: Claude Opus 5 --- adapter/claude/hooks/on-session-start.sh | 36 +++++++++++++++++------- adapter/codex/hooks/on-session-start.ps1 | 17 +++++++++-- adapter/codex/hooks/on-session-start.sh | 36 +++++++++++++++++------- docs/6.-Adapter.md | 2 +- rules/evolution/cold-start-synthesis.md | 2 +- tests/test_clone_refspec_branch_check.py | 21 +++++++++++++- 6 files changed, 89 insertions(+), 25 deletions(-) diff --git a/adapter/claude/hooks/on-session-start.sh b/adapter/claude/hooks/on-session-start.sh index fe0776f6..882f1b2d 100755 --- a/adapter/claude/hooks/on-session-start.sh +++ b/adapter/claude/hooks/on-session-start.sh @@ -1157,7 +1157,7 @@ fi # --- clone branch fetch surface (outside the diff-only set) --- # Implements rules/evolution/cold-start-synthesis.md "Clone Branch Fetch -# Surface" (issue #1911). A Li+ clone whose remote.origin.fetch carries no +# Surface". A Li+ clone whose remote.origin.fetch carries no # refspec sourced under refs/heads/ resolves tags and moves no branch, and # nothing raises: `fetch --tags` succeeds and a bare `git fetch origin` is a # silent no-op, so the lag accumulates with no surface at all. @@ -1181,12 +1181,29 @@ fi # absent: neither state is evidence about a refspec. CLONE_REFSPEC_EMITTED=0 if [ -e "$LIPLUS_DIR/.git" ] && command -v git >/dev/null 2>&1; then - CLONE_FETCH_REFSPECS=$(git -C "$LIPLUS_DIR" config --get-all remote.origin.fetch 2>/dev/null | tr ' -' ' ' | sed 's/[[:space:]]*$//') - case " $CLONE_FETCH_REFSPECS " in - *refs/heads/*) ;; - *) - emit_section "Clone cannot fetch branches" "${LIPLUS_DIR} - remote.origin.fetch maps no branch. + CLONE_FETCH_RAW=$(git -C "$LIPLUS_DIR" config --get-all remote.origin.fetch 2>/dev/null) + CLONE_FETCH_REFSPECS=$(printf '%s' "$CLONE_FETCH_RAW" | tr '\n' ' ' | sed 's/[[:space:]]*$//') + # Source side only. A refspec is [+]:, and the contract's predicate + # is the src half: refs/heads/ appearing as the dst + # (+refs/tags/v1:refs/heads/mirror) maps none of the remote's branches, so a + # substring test over the whole string reads such a clone as healthy. + # ^ is an exclusion and establishes no mapping either. + CLONE_BRANCH_MAPPED=0 + while IFS= read -r CLONE_REFSPEC; do + [ -n "$CLONE_REFSPEC" ] || continue + case "$CLONE_REFSPEC" in + ^*) continue ;; + esac + CLONE_REFSPEC_SRC="${CLONE_REFSPEC#+}" + CLONE_REFSPEC_SRC="${CLONE_REFSPEC_SRC%%:*}" + case "$CLONE_REFSPEC_SRC" in + refs/heads/*) CLONE_BRANCH_MAPPED=1 ;; + esac + done <$null) - if (-not ($cloneFetchRefspecs -cmatch 'refs/heads/')) { + # Source side only, as in the two bash ports: the src half of [+]:, + # skipping ^ exclusions. Ordinal comparison, not StartsWith's + # culture-sensitive default -- git ref names are case-sensitive. + $cloneBranchMapped = $false + foreach ($cloneRefspec in $cloneFetchRefspecs) { + if (-not $cloneRefspec) { continue } + $spec = $cloneRefspec.Trim() + if (-not $spec -or $spec.StartsWith('^', [System.StringComparison]::Ordinal)) { continue } + $src = $spec.TrimStart('+') + $colon = $src.IndexOf(':') + if ($colon -ge 0) { $src = $src.Substring(0, $colon) } + if ($src.StartsWith('refs/heads/', [System.StringComparison]::Ordinal)) { $cloneBranchMapped = $true } + } + if (-not $cloneBranchMapped) { $configured = if ($cloneFetchRefspecs -and ($cloneFetchRefspecs -join ' ').Trim()) { ($cloneFetchRefspecs -join ' ').Trim() } else { '(none)' } Emit-Section 'Clone cannot fetch branches' @" $liplusDir - remote.origin.fetch maps no branch. diff --git a/adapter/codex/hooks/on-session-start.sh b/adapter/codex/hooks/on-session-start.sh index d357a2a2..0baa81e4 100644 --- a/adapter/codex/hooks/on-session-start.sh +++ b/adapter/codex/hooks/on-session-start.sh @@ -903,7 +903,7 @@ fi # --- clone branch fetch surface (outside the diff-only set) --- # Implements rules/evolution/cold-start-synthesis.md "Clone Branch Fetch -# Surface" (issue #1911). A Li+ clone whose remote.origin.fetch carries no +# Surface". A Li+ clone whose remote.origin.fetch carries no # refspec sourced under refs/heads/ resolves tags and moves no branch, and # nothing raises: `fetch --tags` succeeds and a bare `git fetch origin` is a # silent no-op, so the lag accumulates with no surface at all. @@ -927,12 +927,29 @@ fi # absent: neither state is evidence about a refspec. CLONE_REFSPEC_EMITTED=0 if [ -e "$LIPLUS_DIR/.git" ] && command -v git >/dev/null 2>&1; then - CLONE_FETCH_REFSPECS=$(git -C "$LIPLUS_DIR" config --get-all remote.origin.fetch 2>/dev/null | tr ' -' ' ' | sed 's/[[:space:]]*$//') - case " $CLONE_FETCH_REFSPECS " in - *refs/heads/*) ;; - *) - emit_section "Clone cannot fetch branches" "${LIPLUS_DIR} - remote.origin.fetch maps no branch. + CLONE_FETCH_RAW=$(git -C "$LIPLUS_DIR" config --get-all remote.origin.fetch 2>/dev/null) + CLONE_FETCH_REFSPECS=$(printf '%s' "$CLONE_FETCH_RAW" | tr '\n' ' ' | sed 's/[[:space:]]*$//') + # Source side only. A refspec is [+]:, and the contract's predicate + # is the src half: refs/heads/ appearing as the dst + # (+refs/tags/v1:refs/heads/mirror) maps none of the remote's branches, so a + # substring test over the whole string reads such a clone as healthy. + # ^ is an exclusion and establishes no mapping either. + CLONE_BRANCH_MAPPED=0 + while IFS= read -r CLONE_REFSPEC; do + [ -n "$CLONE_REFSPEC" ] || continue + case "$CLONE_REFSPEC" in + ^*) continue ;; + esac + CLONE_REFSPEC_SRC="${CLONE_REFSPEC#+}" + CLONE_REFSPEC_SRC="${CLONE_REFSPEC_SRC%%:*}" + case "$CLONE_REFSPEC_SRC" in + refs/heads/*) CLONE_BRANCH_MAPPED=1 ;; + esac + done </memory` という per-slug レイアウトが存在せず、両 port とも workspace-local な `memory/` しか見ないため、構造的にこの欠陥に到達しない。この命題の検証面は claude 側 hook の内側ではなく codex 側の2ファイルにあり、`.claude/projects` で両ファイルを走査すれば足りる — 該当はレイアウト不在を述べたコメント各1行のみで、cross-slug glob は存在しない。parity を理由に同形の変更を持ち込まないこと - self-evolution observation surface(`memory/self-evolution-observation.md` の check window が開いたエントリ):`verdict_state: pending` のうち `next_check <= today` を `DUE`、`expires < today` を `OVERDUE (human judgment needed)` として列挙する。ファイル解決は promotion candidates と同じ `MEMORY_DIR` 経路を再利用し、ファイル不在・該当エントリ無しは silent skip。**section key を持たない = diff-only 比較対象外**(理由は下記「Diff-only 出力」を参照)。動作契約の正本は `rules/evolution/cold-start-synthesis.md` の Self-Evolution Observation Surface 節 - promotion tally expiry surface(`memory/promotion_tally.md` の 3d 窓が閉じた cluster):`expires < today` を `OVERDUE (threshold judgment not taken)`、`expires <= today` を `DUE` として列挙し、行末に occurrence 件数を併記する(どの Threshold Rules 行が当たるかを選ぶのは件数のため)。tally 書式は verdict 欄を持たないので状態は読まない — 閾値判定のどの帰結も cluster を消すため、書かれたまま残っている cluster は判定が未了であることそのものである。ファイル解決は observation surface と同じ `MEMORY_DIR` 経路を再利用し、ファイル不在・該当 cluster 無しは silent skip。**section key を持たない = diff-only 比較対象外**(理由は下記「Diff-only 出力」を参照)。動作契約の正本は `rules/evolution/cold-start-synthesis.md` の Promotion Tally Expiry Surface 節、判定側の正本は `rules/evolution/promotion-judgment.md` の Threshold Rules 節 -- clone branch fetch surface(LI_PLUS_REPO clone の `remote.origin.fetch` が branch を 1 本も引けない状態):source 側を `refs/heads/` 以下に持つ refspec が 1 本も無い場合に、設定済み refspec と期待する形を併記して列挙する。述語は wildcard の literal 一致ではない(`--single-branch` の clone は branch を追えており該当しない)。`.git` を持たないディレクトリ(api mode)と `git` 不在は silent skip。**section key を持たない = diff-only 比較対象外**(理由は下記「Diff-only 出力」を参照)。既存 2 面と違い trigger は日付駆動ではなく **state 駆動**であり、lifecycle を持たない —— 条件が成立しなくなった時点で出なくなるため、除去のための判定を要しない。`LI_PLUS_UPDATE_STATUS` には載せない(同 marker は adapter startup 手順 step 2 の起動条件であり reason で分岐しないため、検出のみの検査が更新手続きを起動する経路を持ってしまう)。動作契約の正本は `rules/evolution/cold-start-synthesis.md` の Clone Branch Fetch Surface 節 +- clone branch fetch surface(LI_PLUS_REPO clone の `remote.origin.fetch` が branch を 1 本も引けない状態):source 側を `refs/heads/` 以下に持つ refspec が 1 本も無い場合に、設定済み refspec と期待する形を併記して列挙する。述語は wildcard の literal 一致ではなく、また refspec 全体の部分文字列一致でもない —— `[+]:` の src 側だけを見る(`--single-branch` の clone は branch を追えており該当しない。逆に `+refs/tags/v1:refs/heads/mirror` は dst 側にしか `refs/heads/` が無く branch を 1 本も引かない)。`^` は exclusion であり、どの namespace を指していても mapping を成立させないため述語を満たさない。`.git` を持たないディレクトリ(api mode)と `git` 不在は silent skip。**section key を持たない = diff-only 比較対象外**(理由は下記「Diff-only 出力」を参照)。既存 2 面と違い trigger は日付駆動ではなく **state 駆動**であり、lifecycle を持たない —— 条件が成立しなくなった時点で出なくなるため、除去のための判定を要しない。`LI_PLUS_UPDATE_STATUS` には載せない(同 marker は adapter startup 手順 step 2 の起動条件であり reason で分岐しないため、検出のみの検査が更新手続きを起動する経路を持ってしまう)。動作契約の正本は `rules/evolution/cold-start-synthesis.md` の Clone Branch Fetch Surface 節 #### 起動時ステータスマーカー(Li+ update status / Li+config: unrecognized value / Li+ language contract / gh install) diff --git a/rules/evolution/cold-start-synthesis.md b/rules/evolution/cold-start-synthesis.md index 595baad5..85d2c484 100644 --- a/rules/evolution/cold-start-synthesis.md +++ b/rules/evolution/cold-start-synthesis.md @@ -102,7 +102,7 @@ The Li+ clone's configured fetch refspecs are surfaced at cold-start when none o Surface target: - the workspace holds a Li+ clone, and `remote.origin.fetch` carries no refspec whose source side is under `refs/heads/` -> surface as "clone cannot fetch branches" -The predicate is the source side of a refspec, not the wildcard literal. A clone made with `--single-branch` carries `+refs/heads/:refs/remotes/origin/`; it does move that branch and is not this condition, so matching on `refs/heads/*` reports it every session. +The predicate is the source side of a refspec, not the wildcard literal. A clone made with `--single-branch` carries `+refs/heads/:refs/remotes/origin/`; it does move that branch and is not this condition, so matching on `refs/heads/*` reports it every session. Read the source side as the src half of `[+]:`, and read it there only: `refs/heads/` reached on the dst side (`+refs/tags/v1:refs/heads/mirror`) maps none of the remote's branches, so a predicate that tests the refspec as one string reads that clone as healthy. A `^` exclusion satisfies the predicate in no namespace, since it establishes no mapping at all. What such a clone does instead of failing is what makes it need a surface: tags still resolve, so a `fetch --tags` succeeds, a bare `git fetch origin` succeeds as a no-op, and no branch moves. Nothing raises, and the update-status axes read tags, which are the refs that do advance. diff --git a/tests/test_clone_refspec_branch_check.py b/tests/test_clone_refspec_branch_check.py index b642970f..baffb485 100644 --- a/tests/test_clone_refspec_branch_check.py +++ b/tests/test_clone_refspec_branch_check.py @@ -72,6 +72,12 @@ WILDCARD = "+refs/heads/*:refs/remotes/origin/*" SINGLE_BRANCH = "+refs/heads/main:refs/remotes/origin/main" TAG_ONLY = "+refs/tags/build-2026-04-12.8:refs/tags/build-2026-04-12.8" +# `refs/heads/` on the destination side only. It maps none of the remote's +# branches, so a predicate matching the refspec as one string reads this clone +# as healthy while it is the defect. +DEST_ONLY = "+refs/tags/v1:refs/heads/mirror" +# An exclusion establishes no mapping, so it is not a branch mapping either. +EXCLUDE_ONLY = "^refs/heads/main" PORTS = ( "adapter/claude/hooks/on-session-start.sh", @@ -230,6 +236,19 @@ def test_no_refspec_at_all_is_reported(self) -> None: with self.subTest(adapter=adapter): self.assertReported(adapter) + def test_refs_heads_on_the_destination_side_is_reported(self) -> None: + """The predicate is the source side, not the refspec as one string.""" + make_clone(self.ws.liplus, DEST_ONLY) + for adapter in ADAPTERS: + with self.subTest(adapter=adapter): + self.assertReported(adapter) + + def test_exclusion_refspec_alone_is_reported(self) -> None: + make_clone(self.ws.liplus, EXCLUDE_ONLY) + for adapter in ADAPTERS: + with self.subTest(adapter=adapter): + self.assertReported(adapter) + def test_wildcard_refspec_is_silent(self) -> None: make_clone(self.ws.liplus, WILDCARD) for adapter in ADAPTERS: @@ -245,7 +264,7 @@ def test_single_branch_refspec_is_silent(self) -> None: def test_tags_alongside_a_branch_are_silent(self) -> None: """A branch mapping decides, not the count of tag mappings.""" - make_clone(self.ws.liplus, TAG_ONLY, WILDCARD) + make_clone(self.ws.liplus, TAG_ONLY, DEST_ONLY, EXCLUDE_ONLY, WILDCARD) for adapter in ADAPTERS: with self.subTest(adapter=adapter): self.assertSilent(adapter)