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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
# Changelog

## 23
- `update` re-renders `.claude/` when the version is current but the generated files are not (`init --check`) — seen when the submodule was moved by hand; if they stay stale after `init` (edited by hand), it says so instead of re-rendering on every run.
- `task new|set` rejects duplicate check names: results and logs are kept per name since v21.

## 22
- A `PreToolUse` hook (installed by `init`, delivered by `update`) refuses editing project files through Bash and points the agent to Edit/Write; programs, reads and temporary files pass. In the first project agents made ≈1200 such edits and no Edit calls; the v20 rule alone was not expected to change that.

Expand Down
2 changes: 1 addition & 1 deletion lib/dpcli/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,4 @@
else in the current copy. Exit codes: 0 ok, 1 error/check failed, 2 arguments.
"""

VERSION = 22 # bump on every change: older copies re-exec from the main copy (reexec.maybe_reexec)
VERSION = 23 # bump on every change: older copies re-exec from the main copy (reexec.maybe_reexec)
4 changes: 4 additions & 0 deletions lib/dpcli/commands/task.py
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,10 @@ def set_timeouts(card, pairs):
def validate_checks(checks):
"""Card checks are parsed right away (task new/set), not only in accept."""
from .accept import parse_expect
names = [c.get("name") for c in checks or [] if isinstance(c, dict)]
dup = sorted({n for n in names if n and names.count(n) > 1})
if dup: # results and logs are kept by check name
raise DpError(f"duplicate check names: {', '.join(dup)} (task set … --drop-check NAME replaces one)")
for c in checks or []:
if not isinstance(c, dict) or not c.get("name"):
raise DpError(f"check {json.dumps(c, ensure_ascii=False)}: object with name required")
Expand Down
25 changes: 20 additions & 5 deletions lib/dpcli/commands/update.py
Original file line number Diff line number Diff line change
Expand Up @@ -84,24 +84,39 @@ def update_dist(a):
pin = tag_num((read_json(pin_file()) or {}).get("pin") or "")
cand = [n for n in tags if pin is None or n <= pin]
if not cand or cand[-1] <= VERSION:
if not a.check and run(sys.executable, str(reexec.entry()), "init", "--check", cwd=root).returncode:
# the version is current but .claude/ is not (dpcli changed in place, or init never ran): re-render
h, err = render_and_commit(root, f"dpcli v{VERSION}")
if err:
return [f"dpcli v{VERSION}: .claude is stale and init failed — fix and run {CLI} init: {err}"]
if run(sys.executable, str(reexec.entry()), "init", "--check", cwd=root).returncode:
return [f"dpcli v{VERSION}: .claude stays stale after init (edited by hand?) — see {CLI} init --check"]
return [f"dpcli v{VERSION}: .claude re-rendered" + (f", committed {h}" if h else "")]
return [f"dpcli v{VERSION} is up to date" + (f" (pinned v{pin})" if pin else "")] if not a.auto else []
target = cand[-1]
if a.check:
return [f"dpcli v{VERSION} → v{target} available: {CLI} update"]
r = run("git", "checkout", "-q", f"v{target}", cwd=dist)
if r.returncode:
return [f"update: checkout v{target} failed: {r.stderr.strip()}"]
h, err = render_and_commit(root, f"dpcli v{target}")
if err:
return [f"update: v{target} checked out, but init failed — fix and run {CLI} init: {err}"]
notes = changelog(dist, f"v{target}", VERSION, target)
return [f"dpcli v{VERSION} → v{target}" + (f", committed {h}" if h else "")] + [f" {x}" for x in notes]


def render_and_commit(root, msg):
"""init + commit of the generated files. -> (commit hash or "" when nothing changed, None | init's error)."""
r = run(sys.executable, str(reexec.entry()), "init", cwd=root)
if r.returncode:
return [f"update: v{target} checked out, but init failed — fix and run {CLI} init: {short(r.stderr.strip(), 200)}"]
return "", short((r.stderr or r.stdout).strip(), 200) or f"exit {r.returncode}"
wf = config.repo_path(config.get("workflow"), root=root).resolve()
extra = [str(wf.relative_to(root.resolve()))] if root.resolve() in wf.parents else [] # outside the repo: not ours to commit
paths = [p for p in GENERATED + extra if (root / p).exists()]
h = ""
if run("git", "status", "--porcelain", "--", *paths, cwd=root).stdout.strip():
h = gitx.commit_paths(root, paths, lang_commit(f"dpcli v{target}", f"dpcli v{target}"))
notes = changelog(dist, f"v{target}", VERSION, target)
return [f"dpcli v{VERSION} → v{target}" + (f", committed {h}" if h else "")] + [f" {x}" for x in notes]
return gitx.commit_paths(root, paths, lang_commit(msg, msg)), None
return "", None


def check_prs():
Expand Down
7 changes: 7 additions & 0 deletions tests/test_main_copy_review.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,13 @@ def test_logs_named_by_check(self):
logs = sorted(f.name for f in (self.p.root / "build" / "dpcli" / "AA-1").glob("*.log"))
self.assertEqual(logs, ["one.log", "two.log"])

def test_duplicate_check_names_rejected(self):
code, _, err = self.p.run("task", "new", "aa", "--type", "dp-engineer", "--title", "T", "--goal", "g",
"--scope", "src/", "--check", "x", "true", "exit=0", "--check", "x", "false", "exit=0",
"--no-copy")
self.assertEqual(code, 1)
self.assertIn("duplicate check names: x", err)

def test_feedback_module_shows_only_matching_clusters(self):
self.p.ok("module", "init", "bb", "--code", "BB")
self.p.ok("feedback", "new", "Only in bb", "--match", "zzz", "--scope", "dpcli")
Expand Down
25 changes: 25 additions & 0 deletions tests/test_update.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,31 @@ def test_updates_rerenders_and_commits(self):
self.assertIn(f"dpcli v{n}", git("log", "-1", "--format=%s", cwd=self.p.root))
self.assertIn("up to date", self.p.ok("update", env=self.env))

def test_current_version_but_stale_claude_is_rerendered(self):
(self.p.root / ".claude" / "dpcli-workflow.md").unlink()
self.p.commit_all("lost the rendered workflow")
out = self.p.ok("update", env=self.env)
self.assertIn(".claude re-rendered", out)
self.assertTrue((self.p.root / ".claude" / "dpcli-workflow.md").is_file())
self.assertEqual(git("status", "--porcelain", "--", ".claude", cwd=self.p.root), "")

def test_hand_edited_generated_file_is_reported_not_rerendered_in_a_loop(self):
f = self.p.root / ".claude" / "agents" / "dp-engineer.md"
f.write_text(f.read_text() + "\nhand edit\n")
self.p.commit_all("hand edit")
out = self.p.ok("update", env=self.env)
self.assertIn("stays stale after init", out)
self.assertIn("hand edit", f.read_text()) # init never overwrites a hand edit

def test_init_failure_at_current_version_is_reported_with_its_cause(self):
(self.p.root / ".claude" / "dpcli-workflow.md").unlink()
bad = self.p.root / ".dpcli-local" / "agents" / "dp-broken.md"
bad.write_text("---\nname: dp-broken\ndpcli_role: nonsense\n---\nx\n")
self.p.commit_all("broken local agent")
out = self.p.ok("update", env=self.env)
self.assertIn("init failed", out)
self.assertIn("dpcli_role", out)

def test_skips_dirty_and_improve_branch(self):
self.release(self.v0 + 1)
(self.p.root / ".dpcli" / "x.txt").write_text("wip")
Expand Down
Loading