diff --git a/BACKLOG.md b/BACKLOG.md index ebcb91e..84aebbe 100644 --- a/BACKLOG.md +++ b/BACKLOG.md @@ -86,7 +86,7 @@ CC-001/CC-002 were consumed by PR #24 fix bundle inline, with no standalone entr | CC-104u | ✅ closed 2026-05-19 | **[Windows dogfood r4 finding]** `install.sh` `link()` semantics bug on existing-directory dst: when `dst` is already a directory (e.g. `~/.claude/.pm` is a real dir from a prior install or manual setup), `ln -s "$src" "$dst"` is interpreted as "create link inside the dir named $(basename "$src")" → produces `dst/basename(src)` (e.g. `.pm/pm`) instead of failing cleanly. CC-104c's link_or_copy inherits this from `ln`. Observed: `ln: failed to create symbolic link '/c/Users/Lien Chen/.claude/.pm/pm': File exists`. Copy fallback masked the symptom but `manifest` records a wrong dst. Fix: `link_or_copy` should `[[ -d "$dst" && ! -L "$dst" ]]` precheck → return CONFLICT (rc=2) with clear message, OR use `ln -sn` (no-dereference) consistently. Also audit `pm-schema` install block path-handling | ops/install | 2026-05-18 | pr:#100 | P2 | oss | | CC-104v | ✅ closed 2026-05-21 | **[Windows dogfood r4 — docs]** Document copy-mode install snapshot semantics: when `link_or_copy` falls back to copy (Git Bash without dev-mode), changes to source repo do NOT propagate to install dst — user must re-run `install.sh` after any source edit. Currently surfaced only via per-file `portable: fallback copy path ... symlink post-check failed` stderr. Add a single summary banner at end of install when copy-mode entries > 0 (`N files installed via copy fallback; source edits will require re-install`). Also add section to `docs/platform-support.md` Windows page. UX, not correctness | docs/install/ux | 2026-05-18 | pr:#116 | — | oss | | CC-200 | ⏸ deferred | **[Reuse debt]** `scripts/lib/executor-router.sh` — 抽出共用 codex/claude routing logic(目前 `/pm`、`/pr-gate` 各寫一套,未來 N=3 consumer 痛點) | arch/reuse | 2026-05-17 | — | — | reuse-debt | -| CC-201 | ⏸ deferred | **[Reuse debt]** `detect_executor_profile()` shim 進 `scripts/lib/portable.sh` — `install-hooks.sh` + `pr-gate.sh` 各自重複 `command -v codex` 判斷 | arch/reuse | 2026-05-17 | — | — | reuse-debt | +| CC-201 | ✅ closed 2026-05-23 | **[Reuse debt]** `detect_executor_profile()` shim 進 `scripts/lib/portable.sh` — `install-hooks.sh` + `pr-gate.sh` 各自重複 `command -v codex` 判斷 | arch/reuse | 2026-05-17 | pr:#123 | — | reuse-debt | | CC-202 | ⏸ deferred | **[Reuse debt]** handover validator framework — `dispatch_handover_v1` 與 `pr-gate-handover_v1` 共用 fence/metadata/body validator 抽象;future handover schemas 不再手刻 | arch/reuse | 2026-05-17 | — | — | reuse-debt | | CC-203 | ⏸ deferred | **[Reuse debt]** `scripts/lib/test-harness.sh` — 8+ 個 `test-*.sh` 都各寫 `--filter/--list`/`should_run()`/PASS-FAIL counter/scratch dir setup;source-able 共用 lib 統一 | ops/test/reuse | 2026-05-17 | — | — | reuse-debt | | CC-204 | ⏸ deferred | **[Reuse debt]** hook framework — pm-write-guard/codex-bash-guard/codex-write-guard/routing-log 共通 stdin-json-parse → decision-matrix → audit-log 結構;目前 copy-paste-modify | arch/hook/reuse | 2026-05-17 | — | — | reuse-debt | @@ -1037,11 +1037,14 @@ reusing the same agent/fan-out primitives for a different cognitive mode. **Why**: A third consumer would turn the duplicated route logic into a maintenance cost and make executor behavior easier to drift. **Requirement**: Extract shared codex/claude routing into `scripts/lib/executor-router.sh`, preserving existing CLI behavior for current callers. -## CC-201 — Reuse debt: `detect_executor_profile()` shim(deferred) +## CC-201 — Reuse debt: `detect_executor_profile()` shim ✅ 2026-05-23 + +**See**: PR #123 **Problem**: `install-hooks.sh` and `pr-gate.sh` both repeat `command -v codex` style executor-profile detection. **Why**: Profile detection should be consistent across install and dispatch paths. **Requirement**: Move executor-profile detection into a shared shim, likely `scripts/lib/portable.sh` or a focused executor helper, and update both consumers. +**Closed**: `codex_available()` + `detect_executor_profile()` added to `scripts/lib/portable.sh`; `install-hooks.sh` / `pr-gate.sh` / `doctor.sh` (×2) consumers updated; `pr-gate.sh` + `doctor.sh` source the shim behind a graceful copy-mode fallback. +4 `test-portable.sh` cases, +1 `test-doctor.sh` regression case. PR #123 (v0.3.0 M0). ## CC-202 — Reuse debt: handover validator framework(deferred) diff --git a/scripts/doctor.sh b/scripts/doctor.sh index 24cb847..e059672 100755 --- a/scripts/doctor.sh +++ b/scripts/doctor.sh @@ -31,6 +31,10 @@ else esac ;; esac } + # Copy-mode parity: portable.sh would supply codex_available(); define a + # matching fallback so check_codex() / the hook-profile case do not hit an + # undefined function when lib/ is absent (CC-201). + codex_available() { command -v codex >/dev/null 2>&1; } fi if [[ "$_PORTABLE_AVAILABLE" -eq 1 && -f "$SCRIPT_DIR/lib/memory-dir.sh" ]]; then @@ -158,7 +162,7 @@ check_claude() { } check_codex() { - if command -v codex >/dev/null 2>&1; then + if codex_available; then emit_check codex ok "codex available" else emit_check codex warn "codex not found — full-profile hooks (hook-codex-bash-guard.sh etc.) will be skipped; minimal profile active" \ @@ -280,7 +284,7 @@ check_hooks() { case "$PROFILE" in full) _want_full=1 ;; minimal) _want_full=0 ;; - *) command -v codex >/dev/null 2>&1 && _want_full=1 || _want_full=0 ;; + *) if codex_available; then _want_full=1; else _want_full=0; fi ;; esac if [[ "$_want_full" -eq 1 ]]; then profile="full" diff --git a/scripts/install-hooks.sh b/scripts/install-hooks.sh index 40c0798..0cbc619 100755 --- a/scripts/install-hooks.sh +++ b/scripts/install-hooks.sh @@ -72,11 +72,7 @@ case "$PLATFORM" in esac if [[ -z "$PROFILE" ]]; then - if command -v codex >/dev/null 2>&1; then - PROFILE=full - else - PROFILE=minimal - fi + PROFILE="$(detect_executor_profile)" fi if [[ "$PLATFORM" == "auto" ]]; then diff --git a/scripts/lib/portable.sh b/scripts/lib/portable.sh index 1befbc2..09f7558 100644 --- a/scripts/lib/portable.sh +++ b/scripts/lib/portable.sh @@ -62,6 +62,21 @@ detect_platform() { printf 'unknown\n' } +# Return 0 if the `codex` CLI is on PATH, 1 otherwise. +codex_available() { + command -v codex >/dev/null 2>&1 +} + +# Echo the auto-detected executor profile: "full" when codex is available, +# "minimal" otherwise. +detect_executor_profile() { + if codex_available; then + printf 'full\n' + else + printf 'minimal\n' + fi +} + # Resolve a path with GNU-like normalization for use as a cross-platform shim. realpath_m() { local path="${1:-}" diff --git a/scripts/pr-gate.sh b/scripts/pr-gate.sh index b26e998..361c20e 100755 --- a/scripts/pr-gate.sh +++ b/scripts/pr-gate.sh @@ -78,16 +78,6 @@ case "$EXECUTOR_OPTION" in ;; esac -if [[ "$EXECUTOR_OPTION" == "auto" ]]; then - if command -v codex >/dev/null 2>&1; then - EXECUTOR="codex" - else - EXECUTOR="claude" - fi -else - EXECUTOR="$EXECUTOR_OPTION" -fi - cd "$WORK_DIR" # ── Detect base branch ──────────────────────────────────────────────────────── @@ -219,6 +209,25 @@ while [[ -L "$_self" ]]; do [[ "$_self" == /* ]] || _self="$_self_dir/$_self" done SCRIPT_DIR="$(cd "$(dirname "$_self")" && pwd)" +# Source portable.sh for codex_available(); graceful fallback for standalone +# copies (test harness, copy-mode install) where the sibling lib/ is absent. +if [[ -f "$SCRIPT_DIR/lib/portable.sh" ]]; then + # shellcheck source=scripts/lib/portable.sh + . "$SCRIPT_DIR/lib/portable.sh" +else + codex_available() { command -v codex >/dev/null 2>&1; } +fi + +if [[ "$EXECUTOR_OPTION" == "auto" ]]; then + if codex_available; then + EXECUTOR="codex" + else + EXECUTOR="claude" + fi +else + EXECUTOR="$EXECUTOR_OPTION" +fi + unset _self _self_dir # Track all brief files for EXIT cleanup diff --git a/scripts/test-doctor.sh b/scripts/test-doctor.sh index 03acf80..47e715e 100755 --- a/scripts/test-doctor.sh +++ b/scripts/test-doctor.sh @@ -967,6 +967,37 @@ case_doctor_copy_mode_no_lib() { fi } +case_doctor_copy_mode_no_lib_no_codex() { + # CC-201 regression: in copy-mode (no lib/portable.sh) codex_available() + # comes from doctor.sh's own fallback block, not portable.sh. Verify + # doctor.sh still runs gracefully when codex is ALSO absent from PATH — + # the fallback codex_available must be defined so check_codex() and the + # hook-profile case degrade to a warning rather than hitting an undefined + # function. + # + # Steps: + # 1. Copy doctor.sh to a temp dir with no lib/ subdirectory. + # 2. Run it with a PATH containing claude but NOT codex. + # 3. Assert exit 0 or 1 (no crash) and output contains "Summary:". + local name="doctor-copy-mode-no-lib-no-codex" + should_run "$name" || return 0 + local copydir="$tmp_root/copy-scripts-nocodex" + mkdir -p "$copydir" + cp "$DOCTOR" "$copydir/doctor.sh" + local home="$tmp_root/home-copy-nocodex" out status=0 + mkdir -p "$home/.claude" + write_minimal_settings "$home" + write_manifest "$home" + local path + path="$(make_stub_bin "$tmp_root/bin-copy-nocodex" claude)" + out="$(HOME="$home" CLAUDE_CONFIG_DIR="$home/.claude" PATH="$path" bash "$copydir/doctor.sh" --no-color --repo "$REPO_ROOT" 2>&1)" || status=$? + if [[ ( "$status" -eq 0 || "$status" -eq 1 ) && "$out" == *"Summary:"* ]]; then + pass "$name" + else + fail "$name" "expected exit 0 or 1 with Summary: (no crash when codex absent in copy-mode); status=$status out=$out" + fi +} + case_doctor_installed_copy_no_repo() { # Verifies that doctor.sh emits [FAIL] with a helpful message when invoked # in copy-mode (no lib/) without --repo, so the user is not misled by @@ -1155,6 +1186,7 @@ case_doctor_windows_path_hooks_stale case_doctor_stale_hook_path_warns case_doctor_symlink_invocation case_doctor_copy_mode_no_lib +case_doctor_copy_mode_no_lib_no_codex case_doctor_installed_copy_no_repo case_doctor_installed_copy_no_repo_json case_doctor_claude_config_dir diff --git a/scripts/test-portable.sh b/scripts/test-portable.sh index f66f36a..e60c133 100755 --- a/scripts/test-portable.sh +++ b/scripts/test-portable.sh @@ -473,6 +473,78 @@ case_detect_platform_ostype_msys() { fi } +case_codex_available_true_when_stub_present() { + local name="portable-codex-available-true-when-stub-present" + should_run "$name" || return 0 + local old_path="$PATH" + local stub_dir="$tmp_root/codex-stub-available" + mkdir -p "$stub_dir" + printf '#!/usr/bin/env sh\nprintf "codex-stub\\n"\n' > "$stub_dir/codex" + chmod +x "$stub_dir/codex" + + PATH="$stub_dir:$old_path" + if codex_available; then + pass "$name" + else + fail "$name" "expected codex_available success when codex stub is on PATH" + fi + PATH="$old_path" +} + +case_codex_available_false_without_stub() { + local name="portable-codex-available-false-without-stub" + should_run "$name" || return 0 + local old_path="$PATH" + local no_codex_path="$tmp_root/empty-path" + mkdir -p "$no_codex_path" + + PATH="$no_codex_path" + if codex_available; then + fail "$name" "expected codex_available failure when codex is absent from PATH" + else + pass "$name" + fi + PATH="$old_path" +} + +case_detect_executor_profile_full_when_stub_present() { + local name="portable-detect-executor-profile-full-when-stub-present" + should_run "$name" || return 0 + local old_path="$PATH" + local stub_dir="$tmp_root/detect-profile-stub" + local got + mkdir -p "$stub_dir" + printf '#!/usr/bin/env sh\nprintf "codex-stub\\n"\n' > "$stub_dir/codex" + chmod +x "$stub_dir/codex" + + PATH="$stub_dir:$old_path" + got="$(detect_executor_profile)" + PATH="$old_path" + if [[ "$got" == "full" ]]; then + pass "$name" + else + fail "$name" "expected profile 'full' got '$got'" + fi +} + +case_detect_executor_profile_minimal_without_stub() { + local name="portable-detect-executor-profile-minimal-without-stub" + should_run "$name" || return 0 + local old_path="$PATH" + local no_codex_path="$tmp_root/empty-path-profile" + local got + mkdir -p "$no_codex_path" + + PATH="$no_codex_path" + got="$(detect_executor_profile)" + PATH="$old_path" + if [[ "$got" == "minimal" ]]; then + pass "$name" + else + fail "$name" "expected profile 'minimal' got '$got'" + fi +} + case_realpath_m_symlink_resolves() { local name="portable-realpath-m-symlink-resolves" should_run "$name" || return 0 @@ -909,6 +981,10 @@ case_serialize_with_lock_missing_parent case_detect_platform_override_windows case_detect_platform_host_native case_detect_platform_ostype_msys +case_codex_available_true_when_stub_present +case_codex_available_false_without_stub +case_detect_executor_profile_full_when_stub_present +case_detect_executor_profile_minimal_without_stub case_file_size_bytes_returns_size case_file_size_bytes_missing_file case_link_or_copy_copy_refresh_stale() {