diff --git a/Li+update.md b/Li+update.md index 26bbe437..4bf37ede 100644 --- a/Li+update.md +++ b/Li+update.md @@ -128,17 +128,28 @@ 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 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. - 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/hooks/on-session-start.sh b/adapter/claude/hooks/on-session-start.sh index 58d6e103..882f1b2d 100755 --- a/adapter/claude/hooks/on-session-start.sh +++ b/adapter/claude/hooks/on-session-start.sh @@ -1155,6 +1155,69 @@ 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". 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_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) + # 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. + 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) # =================================================================== @@ -1040,11 +1084,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 ee99eacc..0baa81e4 100644 --- a/adapter/codex/hooks/on-session-start.sh +++ b/adapter/codex/hooks/on-session-start.sh @@ -901,6 +901,69 @@ 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". 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_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 一致ではなく、また 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) @@ -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 4326b58a..85767c98 100644 --- a/docs/C.-Update.md +++ b/docs/C.-Update.md @@ -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 が毎セッション 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 完了扱いにしない。最小選択肢は「対象タグへ更新してから続行」「今セッションは現在タグのまま続行」 + e. 人間が更新に同意した場合のみ対象タグへ checkout + f. 現在タグのまま続行を選んだ場合は、現在タグと対象タグを明示してから続行 3. 解決済みタグでソースファイルが参照可能な状態になる。読み込みは Phase 4 が担う --- diff --git a/rules/evolution/cold-start-synthesis.md b/rules/evolution/cold-start-synthesis.md index 2bd7bbcf..85d2c484 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. 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. + +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 new file mode 100644 index 00000000..baffb485 --- /dev/null +++ b/tests/test_clone_refspec_branch_check.py @@ -0,0 +1,326 @@ +"""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 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 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 +-------------- +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 + +import shutil +import subprocess +import unittest +from pathlib import Path + +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 + + +ROOT = Path(__file__).resolve().parents[1] + +# 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" +# `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", + "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") + + +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 decision reaches every surface that carries it.""" + + 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") + for token in BODY_TOKENS: + self.assertIn(token, text) + + 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("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 = 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 emitting any material at all. + workspace.seed_coldstart_rule("CLONE-REFSPEC-FIXTURE") + return workspace + + def output(self, adapter: str) -> str: + out = self.ws.run(adapter, "startup") + self.ws.clear_state() + 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: + 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.assertIsNone(self.surface(self.output(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_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: + 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 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: + """A branch mapping decides, not the count of tag mappings.""" + make_clone(self.ws.liplus, TAG_ONLY, DEST_ONLY, EXCLUDE_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.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()