diff --git a/doc/architecture/shell-helpers.md b/doc/architecture/shell-helpers.md index dc799c2..11fb706 100644 --- a/doc/architecture/shell-helpers.md +++ b/doc/architecture/shell-helpers.md @@ -31,7 +31,7 @@ Nothing project-specific is configured or copied per project: the repo name (the ## State-record writes (`cdd-state`) -Every write is atomic — rendered to a temp file in the destination directory, then `mv`'d into place — so a crashed or concurrent write cannot leave a truncated record. The session chain's ids come from `CLAUDE_CODE_SESSION_ID`: an entry is appended only when the variable is non-empty and differs from the last entry's id (deduping repeated writes within one session); when it is unset (older Claude Code), the entry is omitted rather than guessed. The helper is advisory end-to-end: absent `jq`, or an absent record, it no-ops rather than failing the workflow (writers never fabricate a record; only `/cdd-next-step` seeds one). +Every write is atomic — rendered to a temp file in the destination directory, then `mv`'d into place — so a crashed or concurrent write cannot leave a truncated record. The session chain's ids come from `CLAUDE_CODE_SESSION_ID`: an entry is appended only when the variable is non-empty and differs from the last entry's id (deduping repeated writes within one session); when it is unset (older Claude Code), the entry is omitted rather than guessed. Each entry also carries `dir`, the worktree root the session ran in (`git rev-parse --show-toplevel`) — the natural `cd` target before `claude --resume`. Seeding records the handoff session (`/cdd-next-step`, on the main worktree) as the first entry so it is resumable too, then `set` appends each in-worktree session thereafter. The helper is advisory end-to-end: absent `jq`, or an absent record, it no-ops rather than failing the workflow (writers never fabricate a record; only `/cdd-next-step` seeds one). ### Schema @@ -43,11 +43,11 @@ Every write is atomic — rendered to a temp file in the destination directory, "branch": "task_state_tracking", "stage": "plan_approved", "pr": null, - "sessions": [ { "id": "", "stage": "plan_approved" } ] + "sessions": [ { "id": "", "stage": "plan_approved", "dir": "" } ] } ``` -`pr` is the integer PR number once a PR exists, else `null`. `sessions` is append-only; the last element is the most recent session, and a consumer derives the resume command as `claude --resume `. +`pr` is the integer PR number once a PR exists, else `null`. `sessions` is append-only; the last element is the most recent session, and a consumer derives the resume command as `claude --resume `, run from `dir`. `dir` is additive and optional — not versioned by `schema_version`, so old and new records interoperate; a consumer that finds it absent falls back to the branch's known worktree path. ### Stages and writers @@ -55,7 +55,7 @@ Every write is atomic — rendered to a temp file in the destination directory, | `stage` | written by | | --------------------- | --------------------------------------------------- | -| `scoped` | `/cdd-next-step` — seeds the record (`sessions: []`; it runs on a different session, on the default branch) | +| `scoped` | `/cdd-next-step` — seeds the record and records itself as the first session `{id, stage: scoped, dir}` (empty `sessions` only when no session id is available); it runs on a different session, on the default branch | | `plan_approved` | implementation session — on plan approval, before any code | | `implementation_done` | implementation session — after its local commit | | `merged` | `/cdd-merge-base` — after a successful merge | diff --git a/doc/knowledge_base/roadmap.md b/doc/knowledge_base/roadmap.md index 780a74e..070d8d4 100644 --- a/doc/knowledge_base/roadmap.md +++ b/doc/knowledge_base/roadmap.md @@ -174,7 +174,8 @@ Prepare CDD to be open-sourced publicly: license it, rewrite the README to expla Give each task a machine-readable record of where it sits in its lifecycle and which Claude Code sessions have worked it, so tooling can show task state instead of inferring it from handoffs, branches, and `gh`. -- [x] Per-task state record + `cdd-state` helper: a `.state.json` sibling of the handoff, advanced through the lifecycle by the slash commands via `tools/cdd-state.sh` (atomic `seed`/`set`, self-installing). Advisory, local-only, append-only `{id, stage}` session chain. Full design and schema in process doc §2.13. — §2.13 + §2.6/§2.8/§3.3, all four command copies (repo + template), both `settings.json`, `tools/cdd-state.sh` (new) and `tools/cdd-worktree.sh` (deletion), architecture/feature docs, BOOTSTRAP.md. +- [x] Per-task state record + `cdd-state` helper: a `.state.json` sibling of the handoff, advanced through the lifecycle by the slash commands via `tools/cdd-state.sh` (atomic `seed`/`set`, self-installing). Advisory, local-only, append-only `{id, stage}` session chain. Full design in process doc §2.13; schema in `doc/architecture/shell-helpers.md`. — §2.13 + §2.6/§2.8/§3.3, all four command copies (repo + template), both `settings.json`, `tools/cdd-state.sh` (new) and `tools/cdd-worktree.sh` (deletion), architecture/feature docs, BOOTSTRAP.md. +- [x] Record the handoff session (and per-session working dir) in seeded records (issue #51, writer half; reader half is cdd-dash PR #10): `cdd-state seed` now records the `/cdd-next-step` handoff session as the first `sessions[]` entry so it is resumable, and every session entry carries a `dir` (the worktree root, the `cd` target for `claude --resume`). Additive/optional — no `schema_version` bump; old and new records interoperate. — `tools/cdd-state.sh` (seed + set), `doc/architecture/shell-helpers.md`, `scripts/install-smoke-assert.sh`. - [ ] Harden the one outcome transition a tool call owns: a `PostToolUse` hook on `gh pr create` that parses the PR number and writes `pr_open`/`pr=NN` mechanically (`cdd-state` as the hook target), removing the model-remembering dependency. (A `UserPromptSubmit` hook fires deterministically on every `/cdd-*` call, but only at invocation — it can stamp "stage started", not outcomes like `checks_passed` or the PR number, which stay model-driven via `cdd-state set`.) - [ ] Consume the record: teach the `cdd-dash` dashboard to read `stage`/`sessions` instead of inferring task state. (`cdd-worktree-list` already infers worktree/branch/PR status fine and does not need the record — fold in only if a concrete need appears.) - [x] Multi-machine resume — worktree + branch (issue #22): `cdd-worktree-resume []` recreates a worktree on an existing remote branch (no handoff required, discovery mode when no branch given), ready for a resume-side command. — `tools/cdd-worktree.sh`, process doc §2.8/§2.13, `scripts/worktree-resume-assert.sh` (new) + CI step, README, both `CLAUDE.md` workflow sections. diff --git a/scripts/install-smoke-assert.sh b/scripts/install-smoke-assert.sh index f670513..3fbe60a 100755 --- a/scripts/install-smoke-assert.sh +++ b/scripts/install-smoke-assert.sh @@ -156,4 +156,38 @@ markers=$(grep -cF "CDD state helper (managed by cdd-state.sh install) BEGIN" "$ [[ "$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)" +# `cdd-state seed` must record the handoff session (issue #51): with a session id +# in the environment, the seeded record's first `sessions[]` entry is the current +# session at stage `scoped`, carrying `dir` = the worktree root. Run it from this +# repo (a real git repo) against FAKE_HOME so the record lands under the temp tree. +# Guarded on jq, like the helper itself. +if command -v jq >/dev/null 2>&1; then + SEED_BRANCH="issue51_seed_probe" + # The record path uses the repo name derived from git's common-dir (the main + # worktree), not this checkout's basename — mirror the helper's derivation. + REPO_NAME="$(cd "$REPO_ROOT" && basename "$(dirname "$(git rev-parse --path-format=absolute --git-common-dir)")")" + SEED_FILE="$FAKE_HOME/.cdd/handoffs/$REPO_NAME/$SEED_BRANCH.state.json" + EXPECT_DIR="$(cd "$REPO_ROOT" && git rev-parse --show-toplevel)" + # The helper is dual-mode: executed directly it only installs, so source it and + # call the function (the same path the PATH shim takes) to reach `seed`. + ( cd "$REPO_ROOT" \ + && HOME="$FAKE_HOME" CLAUDE_CODE_SESSION_ID=seed-probe-123 \ + bash -c 'source "$1"; cdd-state seed "$2"' _ "$STATE_HELPER" "$SEED_BRANCH" >/dev/null ) + [[ -f "$SEED_FILE" ]] || fail "seed did not write $SEED_FILE" + got="$(jq -r '.sessions[0] | "\(.id)|\(.stage)|\(.dir)"' "$SEED_FILE")" + [[ "$got" == "seed-probe-123|scoped|$EXPECT_DIR" ]] \ + || fail "seed session entry = '$got', expected 'seed-probe-123|scoped|$EXPECT_DIR'" + pass "cdd-state seed records the handoff session {id, stage: scoped, dir}" + + # Without a session id (older Claude Code), seed keeps sessions empty — no guessing. + ( cd "$REPO_ROOT" \ + && HOME="$FAKE_HOME" CLAUDE_CODE_SESSION_ID='' \ + bash -c 'source "$1"; cdd-state seed "$2"' _ "$STATE_HELPER" "$SEED_BRANCH" >/dev/null ) + count="$(jq -r '.sessions | length' "$SEED_FILE")" + [[ "$count" -eq 0 ]] || fail "seed with no session id left $count session(s), expected 0" + pass "cdd-state seed omits the session entry when no session id is set" +else + echo "skip: jq not found; seed assertions skipped (advisory)" +fi + echo "all install smoke checks passed" diff --git a/tools/cdd-state.sh b/tools/cdd-state.sh index 3989b58..f8feb4d 100755 --- a/tools/cdd-state.sh +++ b/tools/cdd-state.sh @@ -25,16 +25,23 @@ # # Provides (when sourced): # cdd-state seed Create the record beside the handoff, at stage -# `scoped` with an empty `sessions`. Used by -# /cdd-next-step on the default branch. +# `scoped`. Used by /cdd-next-step on the +# default branch. Records the current (handoff) +# session as a {id, stage, dir} entry when +# $CLAUDE_CODE_SESSION_ID is set, so the handoff +# session is resumable too; otherwise seeds an +# empty `sessions` (older Claude Code — no id). # cdd-state set [--pr N] Advance an existing record to (and set # the PR number with --pr). Derives repo/branch # from the current worktree. Skips silently if # the record is absent (writers never fabricate -# one). Appends a {id, stage} entry for +# one). Appends a {id, stage, dir} entry for # $CLAUDE_CODE_SESSION_ID unless it is empty or # already the last entry's id. # +# `dir` on a session entry is the worktree root the session ran in (from +# `git rev-parse --show-toplevel`): the natural `cd` target for `claude --resume`. +# # Stages (a single enum; the record carries no separate status): # scoped plan_approved implementation_done merged checks_passed pr_open addressed @@ -78,11 +85,22 @@ cdd-state() { repo_name="$(basename "$(dirname "$(git rev-parse --path-format=absolute --git-common-dir 2>/dev/null)")")" || return 1 dir="$HOME/.cdd/handoffs/${repo_name}" mkdir -p "$dir" + # Record the handoff session (this /cdd-next-step session, on the main + # worktree) so it is resumable too — guarded exactly like `set`'s append: + # only when CLAUDE_CODE_SESSION_ID is set (older Claude Code → empty list, + # don't guess). `dir` is the worktree root, the `cd` target for --resume. + local sid="${CLAUDE_CODE_SESSION_ID:-}" toplevel sessions='[]' + toplevel="$(git rev-parse --show-toplevel 2>/dev/null)" + if [[ -n "$sid" ]]; then + sessions="$(jq -n --arg id "$sid" --arg dir "$toplevel" \ + '[{id: $id, stage: "scoped", dir: $dir}]')" || return 1 + fi local content content="$(jq -n \ --argjson v "$CDD_STATE_SCHEMA_VERSION" \ --arg branch "$branch" \ - '{schema_version: $v, branch: $branch, stage: "scoped", pr: null, sessions: []}')" || return 1 + --argjson sessions "$sessions" \ + '{schema_version: $v, branch: $branch, stage: "scoped", pr: null, sessions: $sessions}')" || return 1 cdd-state-write "${dir}/${branch}.state.json" "$content" \ && echo "Seeded state: ${dir}/${branch}.state.json" ;; @@ -112,16 +130,19 @@ cdd-state() { local filter='.stage = $stage' [[ -n "$pr" ]] && filter="$filter | .pr = (\$pr | tonumber)" # Append this session unless CLAUDE_CODE_SESSION_ID is empty or already the - # last entry's id (dedups repeated writes within one session). - local sid="${CLAUDE_CODE_SESSION_ID:-}" + # last entry's id (dedups repeated writes within one session). `dir` is the + # worktree root this session ran in — the `cd` target for `claude --resume`. + local sid="${CLAUDE_CODE_SESSION_ID:-}" toplevel + toplevel="$(git rev-parse --show-toplevel 2>/dev/null)" if [[ -n "$sid" ]]; then - filter="$filter | if (.sessions[-1].id // \"\") == \$sid then . else .sessions += [{id: \$sid, stage: \$stage}] end" + filter="$filter | if (.sessions[-1].id // \"\") == \$sid then . else .sessions += [{id: \$sid, stage: \$stage, dir: \$dir}] end" fi local content content="$(jq \ --arg stage "$stage" \ --arg pr "$pr" \ --arg sid "$sid" \ + --arg dir "$toplevel" \ "$filter" "$file")" || { echo "cdd-state: failed to update $file" >&2; return 1; } cdd-state-write "$file" "$content" \ && echo "State: $(basename "$file") -> $stage${pr:+ (pr #$pr)}"