Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion doc/knowledge_base/claude-driven-development.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 [<branch>]`, 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):

Expand Down
1 change: 1 addition & 0 deletions doc/knowledge_base/roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<repo>/` → `~/.cdd/handoffs/<repo>/`. Collapsed the placeholder model three → two (`<PROJECT_SLUG>` 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.

Expand Down
62 changes: 60 additions & 2 deletions scripts/install-smoke-assert.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -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/"
Expand All @@ -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"
Expand All @@ -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"
28 changes: 23 additions & 5 deletions tools/cdd-state.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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" <<RCBLOCK
continue
fi
if grep -qF "$marker_begin" "$rc" 2>/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" <<RCBLOCK

${marker_begin}
[[ -f "\$HOME/.cdd/tools/cdd-state.sh" ]] && source "\$HOME/.cdd/tools/cdd-state.sh"
${marker_end}
RCBLOCK
echo "Wired: $rc"
fi
echo "Wired: $rc"
done

# Also expose `cdd-state` as an executable on PATH. The rc `source` line above
Expand Down
69 changes: 49 additions & 20 deletions tools/cdd-worktree.sh
Original file line number Diff line number Diff line change
Expand Up @@ -437,6 +437,11 @@ cdd-worktree-install() {
# is always at least one entry point.
local marker_begin="# --- CDD worktree helper (managed by cdd-worktree.sh install) BEGIN ---"
local marker_end="# --- CDD worktree 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 tell a live
# block from one disabled by commenting and re-enable the latter.
# shellcheck disable=SC2016
local active_re='^[[:space:]]*\[\[ -f "\$HOME/\.cdd/tools/cdd-worktree\.sh" \]\] && source'
local rc rcs=()
[[ -f "$HOME/.bashrc" ]] && rcs+=("$HOME/.bashrc")
[[ -f "$HOME/.zshrc" ]] && rcs+=("$HOME/.zshrc")
Expand All @@ -445,41 +450,65 @@ cdd-worktree-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" <<RCBLOCK
continue
fi
if grep -qF "$marker_begin" "$rc" 2>/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" <<RCBLOCK

${marker_begin}
[[ -f "\$HOME/.cdd/tools/cdd-worktree.sh" ]] && source "\$HOME/.cdd/tools/cdd-worktree.sh"
${marker_end}
RCBLOCK
echo "Wired: $rc"
fi
echo "Wired: $rc"
done

# Also expose the cdd-worktree* commands as executables on PATH. The rc
# `source` line above only reaches INTERACTIVE shells (a stock ~/.bashrc
# returns early for non-interactive shells via its `case $- in *i*` guard), so
# without these shims the commands are "command not found" in a non-interactive
# shell (e.g. Claude Code's Bash tool). Each shim sources the helper and
# dispatches. Interactive shells still prefer the sourced function (functions
# shadow PATH) — which matters for `cdd-worktree`/`-resume`, whose `cd` into the
# new worktree only takes effect in the caller's shell when run as a function;
# the shim keeps the command resolvable, the function keeps the cwd change.
# Expose the commands on PATH too: the rc `source` line only reaches
# interactive shells, so a non-interactive shell (e.g. Claude Code's Bash
# tool) would otherwise get "command not found". The three cwd-changing
# commands ship a shim that FAILS LOUDLY instead of dispatching — a shim runs
# in a subshell and can't change the caller's cwd, so dispatching would
# silently strand the caller. cdd-worktree-list changes no cwd, so it keeps a
# real source+dispatch shim.
local bin_dir="$HOME/.local/bin" cmd
mkdir -p "$bin_dir"
for cmd in cdd-worktree cdd-worktree-resume cdd-worktree-list cdd-worktree-done; do
for cmd in cdd-worktree cdd-worktree-resume cdd-worktree-done; do
cat > "$bin_dir/$cmd" <<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"
$cmd "\$@"
# Managed by cdd-worktree.sh install — cwd-changing command; do not hand-edit.
# Reaching this shim means '$cmd' is not loaded as a function, so its 'cd' can't
# take effect (a subshell can't change its parent's cwd). Refuse loudly.
echo "$cmd must run as a sourced shell function, not via the PATH shim." >&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 ;;
Expand Down
Loading