diff --git a/doc/knowledge_base/claude-driven-development.md b/doc/knowledge_base/claude-driven-development.md index d329c74..da99003 100644 --- a/doc/knowledge_base/claude-driven-development.md +++ b/doc/knowledge_base/claude-driven-development.md @@ -136,7 +136,7 @@ A single, project-independent bash helper provides four commands. It is the same - `cdd-worktree-list`, lists active handoffs with worktree/branch/PR status. Highlights stale entries. - `cdd-worktree-resume []`, picks up a task started on another machine that has only a clone of the repo (no local branch, worktree, or handoff). It fetches `origin` and recreates a worktree tracking an **existing remote branch**, then `cd`s into it, ready for you to run a resume-side command (`/cdd-process-pr`, `/cdd-merge-base`, `/cdd-pre-pr`). With no argument it lists remote feature branches not already checked out and prompts for one; the fetch it runs first uses `--prune`, so branches deleted on the remote (as GitHub does when a PR merges) drop out of the list, leaving just the live branches — the default plus whatever feature branches still exist on the remote, whether or not they yet have a PR. Its scope is deliberately **worktree and branch recreation only**: the handoff (§2.6) and the state record (§2.13) stay local per machine and are *not* transferred — sound, because the resume-side commands read PR/branch state from git and `gh`, not the handoff, and `cdd-state set` no-ops when the record is absent. Cross-machine *sync* of those two artifacts is separate, still-future work (issue #22); the direction is sketched in the roadmap. -The helper installs itself to a stable home that does not depend on a live CDD checkout. Run `tools/cdd-worktree.sh install` once (the script is dual-mode: sourced it defines the functions; run directly with `install` it sets itself up): this copies the script to `~/.cdd/tools/cdd-worktree.sh`, appends a marker-guarded source line to `~/.bashrc` and `~/.zshrc` (idempotent), drops thin PATH shims into `~/.local/bin` (one per command), and migrates any handoffs from the legacy `~/.claude-handoffs/` location. The PATH shims matter because the rc `source` line only reaches **interactive** shells — a stock `~/.bashrc` returns early for non-interactive shells via its `case $- in *i*` guard — so a command invoked from a non-interactive shell (notably Claude Code's Bash tool) would otherwise be "command not found"; the shim sources the helper and dispatches, while interactive shells still prefer the sourced function (functions shadow PATH, which is what lets `cdd-worktree` `cd` the caller into the new worktree). After installing, the commands work in every CDD project — including ones bootstrapped later — without any further per-project setup. +The helper installs itself to a stable home that does not depend on a live CDD checkout. Run `tools/cdd-worktree.sh install` once (the script is dual-mode: sourced it defines the functions; run directly with `install` it sets itself up): this copies the script to `~/.cdd/tools/cdd-worktree.sh`, appends a marker-guarded source line to `~/.bashrc` and `~/.zshrc` (idempotent, and self-repairing — re-running `install` re-enables a disabled block rather than skipping it), drops thin PATH shims into `~/.local/bin`, and migrates any handoffs from the legacy `~/.claude-handoffs/` location. The PATH shims exist because the rc `source` line only reaches interactive shells, so a command run from a non-interactive shell (notably Claude Code's Bash tool) would otherwise be "command not found". The three commands that `cd` the caller's shell (`cdd-worktree`, `cdd-worktree-resume`, `cdd-worktree-done`) ship a shim that fails loudly rather than dispatch, because a shim runs in a subshell and cannot change its parent's cwd; only `cdd-worktree-list` (and `cdd-state`, §2.13), which change no cwd, keep a dispatching shim. After installing, the commands work in every CDD project — including ones bootstrapped later — without any further per-project setup. On a machine without the CDD repo checked out (a fresh clone of only a downstream project), the same one-time install is a single command — fetched to disk first, since `curl … | bash` can't work (the installer copies itself from its own file path): diff --git a/doc/knowledge_base/roadmap.md b/doc/knowledge_base/roadmap.md index 9325caf..19b7246 100644 --- a/doc/knowledge_base/roadmap.md +++ b/doc/knowledge_base/roadmap.md @@ -108,6 +108,7 @@ Make the per-task session loop nicer to drive once a project is already on CDD. - [x] Auto-commit at workflow gates: the implementation session and `/cdd-pre-pr` commit their own changes automatically (local, no push) in a non-disruptive way; `/cdd-process-pr` and `/cdd-merge-main` reviewed to fit one shared commit convention. — issue #20. Process doc §2.11 (new) + §3.3/§3.5/§3.7/§4 + both `cdd-pre-pr.md` and `cdd-next-step.md` copies + both CLAUDE.md `/cdd-pre-pr` bullets. - [x] Prefix every CDD slash command with `cdd-` (`/cdd-next-step`, `/cdd-pre-pr`, `/cdd-merge-main`, `/cdd-process-pr`, `/cdd-bootstrap`, `/cdd-retrofit`, `/cdd-quick-create`) so they autocomplete as a discoverable group. — issue #27. Renamed all 11 command files (7 repo + 4 template), swept every cross-reference across the process doc, template, demo, and docs, and updated `scripts/command-drift-whitelist.txt` + `scripts/command-drift-check.sh`. Scope was slash commands only; the worktree helper was unified separately (next item). - [x] Unify the worktree helper into a single, self-installing, project-independent `cdd-worktree` — issues #24 + #18. Deleted the per-project `template/tools/PROJECT-worktree.sh`; the canonical `tools/cdd-worktree.sh` is now dual-mode (sourced → defines the functions; `install` → copies itself to `~/.cdd/tools/`, wires `~/.bashrc` + `~/.zshrc` idempotently, migrates old handoffs). Handoffs moved `~/.claude-handoffs//` → `~/.cdd/handoffs//`. Collapsed the placeholder model three → two (`` and the bare `PROJECT` token removed; `--slug` dropped from the bootstrap script). Demo installs the shared helper once instead of per-instance rc blocks; drift/smoke checks no longer compare a rendered helper. — process doc §2.6/§2.8/§2.9 + bootstrap script + both settings.json + both `cdd-next-step.md` + `cdd-bootstrap.md`/`cdd-retrofit.md`/`cdd-pre-pr.md` + template/BOOTSTRAP.md + both CLAUDE.md + README + demo subsystem + scripts + CI. +- [x] Fix the worktree PATH shims stranding the caller for cwd-changing commands, and make `install` self-repair a disabled rc block. — `tools/cdd-worktree.sh` + `tools/cdd-state.sh` + process doc §2.8 + `scripts/install-smoke-assert.sh`. **Milestone:** starting an off-roadmap task — typed or sourced from a GitHub issue — is a first-class, structured `/cdd-next-step` flow. diff --git a/scripts/install-smoke-assert.sh b/scripts/install-smoke-assert.sh index 8253282..f670513 100755 --- a/scripts/install-smoke-assert.sh +++ b/scripts/install-smoke-assert.sh @@ -28,6 +28,19 @@ STATE_HELPER="$REPO_ROOT/tools/cdd-state.sh" fail() { echo "FAIL: $*" >&2; exit 1; } pass() { echo "ok: $*"; } +# Disable a managed rc block by prefixing every line from BEGIN to END with "# ", +# simulating a user (or tool) commenting it out — the state `install` must detect +# and self-repair. Matches markers by substring so it works on active blocks. +comment_block() { + local file="$1" begin="$2" end="$3" tmp + tmp="$(mktemp "${file}.XXXXXX")" + awk -v b="$begin" -v e="$end" ' + index($0, b) { skip = 1 } + skip { print "# " $0; if (index($0, e)) skip = 0; next } + { print } + ' "$file" > "$tmp" && mv -f "$tmp" "$file" +} + [[ -x "$HELPER" ]] || fail "helper not found/executable: $HELPER" [[ -x "$STATE_HELPER" ]] || fail "state helper not found/executable: $STATE_HELPER" @@ -68,11 +81,24 @@ pass "cdd-worktree* PATH shims written to ~/.local/bin and executable" # The shim must actually resolve and dispatch from a non-interactive shell with # only ~/.local/bin on PATH and no rc sourced — the exact case it exists for. -# `cdd-worktree-list` is side-effect-free, so use it as the probe. +# `cdd-worktree-list` is side-effect-free AND changes no cwd, so it keeps a real +# source+dispatch shim; use it as the probe. env -i HOME="$FAKE_HOME" PATH="$FAKE_HOME/.local/bin:/usr/bin:/bin" \ bash -c 'command -v cdd-worktree-list >/dev/null && cdd-worktree-list >/dev/null 2>&1' \ || fail "cdd-worktree-list shim did not resolve/dispatch in a non-interactive shell" -pass "cdd-worktree shim resolves and dispatches non-interactively" +pass "cdd-worktree-list shim resolves and dispatches non-interactively" + +# The cwd-changing commands (cdd-worktree, cdd-worktree-resume, cdd-worktree-done) +# must FAIL LOUDLY via the shim rather than dispatch into a subshell whose `cd` +# can't reach the caller — the regression that stranded the user in the removed +# worktree. Probe cdd-worktree-done (the regression subject): the shim exits +# before sourcing anything, so no git state is needed. +done_out="$(env -i HOME="$FAKE_HOME" PATH="$FAKE_HOME/.local/bin:/usr/bin:/bin" \ + bash -c 'cdd-worktree-done' 2>&1)" && \ + fail "cdd-worktree-done shim succeeded silently (should refuse when unsourced)" +grep -qF "must run as a sourced shell function" <<<"$done_out" \ + || fail "cdd-worktree-done shim did not print the sourced-function guidance; got: $done_out" +pass "cwd-changing shim (cdd-worktree-done) fails loudly instead of silently no-op'ing the cd" [[ -f "$FAKE_HOME/.cdd/handoffs/someproj/feature_x.md" ]] \ || fail "legacy handoff not migrated to ~/.cdd/handoffs/" @@ -86,6 +112,23 @@ markers=$(grep -cF "CDD worktree helper (managed by cdd-worktree.sh install) BEG [[ "$markers" -eq 1 ]] || fail "second install duplicated the marker block (found $markers)" pass "second install is idempotent (no duplicate marker block)" +# Self-repair: a managed block that is present but DISABLED (commented out) must be +# rewritten as an active source line on re-install — the case a bare marker grep +# could not see, which left a user whose block got commented unable to re-enable it. +WT_BEGIN="# --- CDD worktree helper (managed by cdd-worktree.sh install) BEGIN ---" +WT_END="# --- CDD worktree helper END ---" +# shellcheck disable=SC2016 +WT_ACTIVE='^[[:space:]]*\[\[ -f "\$HOME/\.cdd/tools/cdd-worktree\.sh" \]\] && source' +comment_block "$FAKE_HOME/.bashrc" "$WT_BEGIN" "$WT_END" +grep -qE "$WT_ACTIVE" "$FAKE_HOME/.bashrc" \ + && fail "precondition: block still active after comment_block" +HOME="$FAKE_HOME" "$HELPER" install >/dev/null +grep -qE "$WT_ACTIVE" "$FAKE_HOME/.bashrc" \ + || fail "install did not self-repair a disabled worktree block to an active source line" +markers=$(grep -cF "CDD worktree helper (managed by cdd-worktree.sh install) BEGIN" "$FAKE_HOME/.bashrc") +[[ "$markers" -eq 1 ]] || fail "self-repair left more than one worktree marker block (found $markers)" +pass "install self-repairs a disabled worktree block (active again, still single)" + # The task-state helper self-installs identically; assert its shim contract too. HOME="$FAKE_HOME" "$STATE_HELPER" install >/dev/null STATE_SHIM="$FAKE_HOME/.local/bin/cdd-state" @@ -98,4 +141,19 @@ resolved=$(env -i HOME="$FAKE_HOME" PATH="$FAKE_HOME/.local/bin:/usr/bin:/bin" \ [[ "$resolved" == "$STATE_SHIM" ]] || fail "cdd-state resolved to '$resolved', expected the shim $STATE_SHIM" pass "cdd-state PATH shim written and resolves non-interactively" +# The cdd-state installer shares the self-repair guard; assert it too. +ST_BEGIN="# --- CDD state helper (managed by cdd-state.sh install) BEGIN ---" +ST_END="# --- CDD state helper END ---" +# shellcheck disable=SC2016 +ST_ACTIVE='^[[:space:]]*\[\[ -f "\$HOME/\.cdd/tools/cdd-state\.sh" \]\] && source' +comment_block "$FAKE_HOME/.bashrc" "$ST_BEGIN" "$ST_END" +grep -qE "$ST_ACTIVE" "$FAKE_HOME/.bashrc" \ + && fail "precondition: state block still active after comment_block" +HOME="$FAKE_HOME" "$STATE_HELPER" install >/dev/null +grep -qE "$ST_ACTIVE" "$FAKE_HOME/.bashrc" \ + || fail "install did not self-repair a disabled state block to an active source line" +markers=$(grep -cF "CDD state helper (managed by cdd-state.sh install) BEGIN" "$FAKE_HOME/.bashrc") +[[ "$markers" -eq 1 ]] || fail "self-repair left more than one state marker block (found $markers)" +pass "install self-repairs a disabled state block (active again, still single)" + echo "all install smoke checks passed" diff --git a/tools/cdd-state.sh b/tools/cdd-state.sh index 7148c20..3989b58 100755 --- a/tools/cdd-state.sh +++ b/tools/cdd-state.sh @@ -160,6 +160,11 @@ cdd-state-install() { local marker_begin="# --- CDD state helper (managed by cdd-state.sh install) BEGIN ---" local marker_end="# --- CDD state helper END ---" + # Match the ACTIVE source line (anchored to line start, so a commented-out + # copy can't match) rather than the bare marker, so `install` can self-repair + # a block disabled by commenting. + # shellcheck disable=SC2016 + local active_re='^[[:space:]]*\[\[ -f "\$HOME/\.cdd/tools/cdd-state\.sh" \]\] && source' local rc rcs=() [[ -f "$HOME/.bashrc" ]] && rcs+=("$HOME/.bashrc") [[ -f "$HOME/.zshrc" ]] && rcs+=("$HOME/.zshrc") @@ -168,17 +173,30 @@ cdd-state-install() { rcs+=("$HOME/.bashrc") fi for rc in "${rcs[@]}"; do - if grep -qF "$marker_begin" "$rc" 2>/dev/null; then + if grep -qE "$active_re" "$rc" 2>/dev/null; then echo "Already wired: $rc (skipped)" - else - cat >> "$rc" </dev/null; then + # Present but inactive (commented/mangled): strip it, then re-append below. + # index() matches the marker substring even when the line is commented. + local tmp + tmp="$(mktemp "${rc}.XXXXXX")" || return 1 + awk -v b="$marker_begin" -v e="$marker_end" ' + index($0, b) { skip = 1 } + skip && index($0, e) { skip = 0; next } + skip { next } + { print } + ' "$rc" > "$tmp" && mv -f "$tmp" "$rc" + echo "Repaired disabled CDD block in $rc" + fi + cat >> "$rc" </dev/null; then + if grep -qE "$active_re" "$rc" 2>/dev/null; then echo "Already wired: $rc (skipped)" - else - cat >> "$rc" </dev/null; then + # A managed block is present but inactive: strip it, then re-append a + # fresh active block below. index() matches the marker even when the + # line is commented ("## # --- … BEGIN ---" still contains the marker). + local tmp + tmp="$(mktemp "${rc}.XXXXXX")" || return 1 + awk -v b="$marker_begin" -v e="$marker_end" ' + index($0, b) { skip = 1 } + skip && index($0, e) { skip = 0; next } + skip { next } + { print } + ' "$rc" > "$tmp" && mv -f "$tmp" "$rc" + echo "Repaired disabled CDD block in $rc" + fi + cat >> "$rc" < "$bin_dir/$cmd" <&2 +echo "It changes your shell's working directory, which a subshell cannot do." >&2 +echo "Fix: open a new shell, or 'source ~/.cdd/tools/cdd-worktree.sh', then re-run." >&2 +echo "If your shell rc CDD block was disabled: bash ~/.cdd/tools/cdd-worktree.sh install" >&2 +exit 1 SHIM chmod +x "$bin_dir/$cmd" done - echo "Installed PATH shims in $bin_dir: cdd-worktree, cdd-worktree-resume, cdd-worktree-list, cdd-worktree-done" + cat > "$bin_dir/cdd-worktree-list" <<'SHIM' +#!/usr/bin/env bash +# Managed by cdd-worktree.sh install — PATH entry point so this command resolves +# in non-interactive shells too. Regenerated on each install; do not hand-edit. +source "$HOME/.cdd/tools/cdd-worktree.sh" +cdd-worktree-list "$@" +SHIM + chmod +x "$bin_dir/cdd-worktree-list" + echo "Installed PATH shims in $bin_dir: cdd-worktree-list (dispatch); cdd-worktree, cdd-worktree-resume, cdd-worktree-done (refuse-if-unsourced)" case ":$PATH:" in *":$bin_dir:"*) ;; *) echo "Note: $bin_dir is not on your PATH; add it so the cdd-worktree* commands resolve everywhere." >&2 ;;