Skip to content

Commit f97e714

Browse files
committed
fix(handoff): safe-append CLAUDE.md via new subcommand (ship-blocker #15)
Closes the "HANDOFF appends to ./CLAUDE.md without dry-run, backup, or conflict detection" issue from docs/v4-release/ship-readiness-discovery.md. Before: phases/HANDOFF.md Step 3 instructed the AI to Read+Edit CLAUDE.md directly, guarded only by an exact-string presence check. A user with a hand-crafted CLAUDE.md could get silent append damage on every re-run that didn't happen to match verbatim. After: new `script.py append-workflow` subcommand handles it deterministically. - HTML-comment sentinels (BEGIN/END prd-taskmaster-v2 workflow) make the check idempotent and survive user edits inside the block. - Timestamped backup (.prd-taskmaster-backup-<ts>) on every append to an existing file — follows the same pattern as backup-prd. - --dry-run previews the planned action + content, returns JSON, writes nothing. Wired into HANDOFF Step 3 for user review before ExitPlanMode. - JSON response with action ∈ {created, skipped, appended, would_*} makes the outcome machine-readable for downstream consumers. HANDOFF.md Step 3 rewritten to call the subcommand instead of expressing raw Read+Edit intent — keeps mutation logic in the deterministic layer, matches the codification contract. Tests: 4 new tests in TestAppendWorkflow covering all four branches (created / appended-with-backup / skipped-on-markers / dry-run-writes-nothing). Full suite: 217 passed, 1 skipped, 0 regressions. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent bf191d9 commit f97e714

File tree

3 files changed

+166
-20
lines changed

3 files changed

+166
-20
lines changed

phases/HANDOFF.md

Lines changed: 36 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -104,26 +104,42 @@ Then **re-invoke the mode picker with Mode D removed from the options.**
104104

105105
## Step 3: Append Task Workflow to CLAUDE.md
106106

107-
Read the project's `./CLAUDE.md`. Append this section (if not already present):
108-
109-
```markdown
110-
## Task Execution Workflow (prd-taskmaster-v2)
111-
112-
When implementing tasks, prefer task-master-ai MCP tools over the CLI:
113-
1. `mcp__task-master-ai__next_task()` or `task-master next` -- get next ready task
114-
2. `set_task_status(id, "in-progress")` -- note hyphen; underscore is rejected
115-
3. Implement the task (follow the plan step linked to this task)
116-
4. `set_task_status(id, "done")` -- mark complete
117-
5. Update TodoWrite with progress
118-
6. Repeat from step 1
119-
120-
Valid statuses: `pending`, `in-progress`, `done`, `review`, `blocked`, `deferred`, `cancelled`.
121-
122-
### Progress Tracking
123-
- Update TodoWrite BEFORE and AFTER each task
124-
- Cannot proceed to next task without updating TodoWrite
125-
- TodoWrite = user visibility. TaskMaster = source of truth.
126-
```
107+
Use the deterministic subcommand — do **not** do raw Read+Edit. This path is idempotent, takes a timestamped backup when modifying an existing file, and uses HTML-comment sentinels so re-runs are no-ops.
108+
109+
1. Write the workflow content to a tempfile (it is the same content every run):
110+
111+
```markdown
112+
## Task Execution Workflow (prd-taskmaster-v2)
113+
114+
When implementing tasks, prefer task-master-ai MCP tools over the CLI:
115+
1. `mcp__task-master-ai__next_task()` or `task-master next` -- get next ready task
116+
2. `set_task_status(id, "in-progress")` -- note hyphen; underscore is rejected
117+
3. Implement the task (follow the plan step linked to this task)
118+
4. `set_task_status(id, "done")` -- mark complete
119+
5. Update TodoWrite with progress
120+
6. Repeat from step 1
121+
122+
Valid statuses: `pending`, `in-progress`, `done`, `review`, `blocked`, `deferred`, `cancelled`.
123+
124+
### Progress Tracking
125+
- Update TodoWrite BEFORE and AFTER each task
126+
- Cannot proceed to next task without updating TodoWrite
127+
- TodoWrite = user visibility. TaskMaster = source of truth.
128+
```
129+
130+
2. **Preview first** with `--dry-run` so the user (in plan mode) can see what would be written:
131+
132+
```bash
133+
python3 $SKILL_DIR/script.py append-workflow \
134+
--target ./CLAUDE.md \
135+
--content-file /tmp/pdtm-workflow-section.md \
136+
--dry-run
137+
```
138+
139+
3. After `ExitPlanMode` approval in Step 5, run the real write (same command without `--dry-run`). The JSON response reports one of:
140+
- `action: "created"` — no prior CLAUDE.md, fresh file with markers
141+
- `action: "skipped"` (reason: `markers_present`) — already wired, no-op
142+
- `action: "appended"` — existing CLAUDE.md untouched except for the appended marker block; `backup_path` points at `CLAUDE.md.prd-taskmaster-backup-<ts>`
127143

128144
## Step 4: Display Summary
129145

script.py

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -945,6 +945,60 @@ def cmd_backup_prd(args: argparse.Namespace) -> None:
945945
})
946946

947947

948+
WORKFLOW_BEGIN_MARKER = "<!-- BEGIN prd-taskmaster-v2 workflow -->"
949+
WORKFLOW_END_MARKER = "<!-- END prd-taskmaster-v2 workflow -->"
950+
951+
952+
def cmd_append_workflow_section(args: argparse.Namespace) -> None:
953+
"""Idempotently append the task-execution workflow section to a CLAUDE.md.
954+
955+
Contract:
956+
- If target missing: create it with marker-wrapped content. action=created.
957+
- If markers already present: no write. action=skipped.
958+
- Else: backup to CLAUDE.md.prd-taskmaster-backup-<ts>.md, then append
959+
marker-wrapped content. action=appended.
960+
- --dry-run: emit the planned action and content, do not write.
961+
962+
Replaces the raw Read+Edit flow in phases/HANDOFF.md Step 3, which had no
963+
backup, no conflict detection beyond exact-string match, and no dry-run.
964+
"""
965+
target = Path(args.target)
966+
content_path = Path(args.content_file)
967+
if not content_path.is_file():
968+
fail(f"Content file not found: {args.content_file}")
969+
content = content_path.read_text()
970+
971+
wrapped = f"\n{WORKFLOW_BEGIN_MARKER}\n{content.rstrip()}\n{WORKFLOW_END_MARKER}\n"
972+
973+
if not target.exists():
974+
action, backup_path = "created", None
975+
else:
976+
existing = target.read_text()
977+
if WORKFLOW_BEGIN_MARKER in existing:
978+
emit({"ok": True, "action": "skipped", "reason": "markers_present",
979+
"target": str(target), "backup_path": None})
980+
return
981+
action = "appended"
982+
backup_path = None
983+
if not args.dry_run:
984+
ts = datetime.now().strftime("%Y%m%d-%H%M%S")
985+
backup_path = str(target.parent / f"{target.name}.prd-taskmaster-backup-{ts}")
986+
shutil.copy2(str(target), backup_path)
987+
988+
if args.dry_run:
989+
emit({"ok": True, "action": f"would_{action}", "target": str(target),
990+
"content_preview": wrapped, "backup_path": None, "dry_run": True})
991+
return
992+
993+
target.parent.mkdir(parents=True, exist_ok=True)
994+
mode = "w" if action == "created" else "a"
995+
with open(target, mode) as f:
996+
f.write(wrapped)
997+
998+
emit({"ok": True, "action": action, "target": str(target),
999+
"backup_path": backup_path, "dry_run": False})
1000+
1001+
9481002
def cmd_read_state(args: argparse.Namespace) -> None:
9491003
"""Read crash recovery state."""
9501004
state = _read_execution_state()
@@ -1715,6 +1769,13 @@ def build_parser() -> argparse.ArgumentParser:
17151769
# validate-setup
17161770
sub.add_parser("validate-setup", help="Run all Phase 0 setup checks with diagnostic output")
17171771

1772+
# append-workflow
1773+
p = sub.add_parser("append-workflow",
1774+
help="Idempotent, backup-safe append of workflow section to CLAUDE.md")
1775+
p.add_argument("--target", required=True, help="Path to CLAUDE.md")
1776+
p.add_argument("--content-file", required=True, help="Path to content to append")
1777+
p.add_argument("--dry-run", action="store_true", help="Preview without writing")
1778+
17181779
return parser
17191780

17201781

@@ -1732,6 +1793,7 @@ def build_parser() -> argparse.ArgumentParser:
17321793
"init-taskmaster": cmd_init_taskmaster,
17331794
"detect-capabilities": cmd_detect_capabilities,
17341795
"validate-setup": cmd_validate_setup,
1796+
"append-workflow": cmd_append_workflow_section,
17351797
}
17361798

17371799

tests/test_script.py

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -859,3 +859,71 @@ def test_now_iso_format(self):
859859
result = self.mod.now_iso()
860860
assert "T" in result
861861
assert "+" in result or "Z" in result
862+
863+
864+
# ═══════════════════════════════════════════════════════════════════════════════
865+
# APPEND-WORKFLOW — ship-blocker #15 (CLAUDE.md safe-append)
866+
# ═══════════════════════════════════════════════════════════════════════════════
867+
868+
869+
class TestAppendWorkflow:
870+
"""Closes ship-readiness issue #15: HANDOFF's raw append had no backup,
871+
no dry-run, and only exact-string idempotency. Exercise all four branches.
872+
"""
873+
874+
def _content(self, tmp_path):
875+
c = tmp_path / "section.md"
876+
c.write_text("## Task Execution Workflow (prd-taskmaster-v2)\nline one\n")
877+
return c
878+
879+
def test_created_when_target_absent(self, tmp_path):
880+
target = tmp_path / "CLAUDE.md"
881+
rc, out = run_script(SCRIPT_PY, [
882+
"append-workflow", "--target", str(target),
883+
"--content-file", str(self._content(tmp_path)),
884+
])
885+
assert rc == 0 and out["action"] == "created"
886+
body = target.read_text()
887+
assert "BEGIN prd-taskmaster-v2 workflow" in body
888+
assert "END prd-taskmaster-v2 workflow" in body
889+
890+
def test_appended_preserves_existing_and_backs_up(self, tmp_path):
891+
target = tmp_path / "CLAUDE.md"
892+
original = "# User CLAUDE.md\n\nmy own stuff\n"
893+
target.write_text(original)
894+
rc, out = run_script(SCRIPT_PY, [
895+
"append-workflow", "--target", str(target),
896+
"--content-file", str(self._content(tmp_path)),
897+
])
898+
assert rc == 0 and out["action"] == "appended"
899+
assert out["backup_path"] is not None
900+
assert Path(out["backup_path"]).read_text() == original
901+
assert target.read_text().startswith(original)
902+
assert "BEGIN prd-taskmaster-v2 workflow" in target.read_text()
903+
904+
def test_skipped_when_markers_present(self, tmp_path):
905+
target = tmp_path / "CLAUDE.md"
906+
target.write_text("# Existing\n<!-- BEGIN prd-taskmaster-v2 workflow -->\n"
907+
"old\n<!-- END prd-taskmaster-v2 workflow -->\n")
908+
before = target.read_text()
909+
rc, out = run_script(SCRIPT_PY, [
910+
"append-workflow", "--target", str(target),
911+
"--content-file", str(self._content(tmp_path)),
912+
])
913+
assert rc == 0 and out["action"] == "skipped"
914+
assert out["reason"] == "markers_present"
915+
assert target.read_text() == before # byte-identical
916+
917+
def test_dry_run_writes_nothing(self, tmp_path):
918+
target = tmp_path / "CLAUDE.md"
919+
original = "# Existing\n"
920+
target.write_text(original)
921+
rc, out = run_script(SCRIPT_PY, [
922+
"append-workflow", "--target", str(target),
923+
"--content-file", str(self._content(tmp_path)),
924+
"--dry-run",
925+
])
926+
assert rc == 0 and out["dry_run"] is True
927+
assert out["action"] == "would_appended"
928+
assert target.read_text() == original # unchanged
929+
assert "BEGIN prd-taskmaster-v2 workflow" in out["content_preview"]

0 commit comments

Comments
 (0)