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 .github/workflows/implement.yml
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
name: implement
# A `feedback` issue from a trusted author (owner, member, collaborator — `dpcli contribute` without a fix) becomes a PR:
# A `feedback` issue from a trusted author (owner, member, collaborator — `dpcli contribute --issue`, a friction reported without a fix) becomes a PR:
# the agent implements it or closes it as not planned with the reason; the PR then goes through review.yml's loop.
on:
issues:
Expand Down
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
# Changelog

## 49
- `contribute` without a fix in `.dpcli` refuses with a hint instead of opening an issue, a `+1` or an offline draft (`--issue` reports on purpose); it refuses a fixed cluster with no friction since the fix, and a Cyrillic title (the hub is public and English); a committed fix bypasses the fixed refusal. Agents ran it on every cluster and filled the hub with issues, including ones for already merged fixes.

## 48
- `plan` with a section that is not found: the table-of-contents hint keeps `--contracts`, and a section named like a file next to the plan points to it (`plan <dir>/<name>.md`). Cards named another module's contracts file as a section of this one, and agents got "section not found" with no way forward (9 reports).

Expand Down
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ In a project that uses dpcli:
1. Frictions are logged as you work: `dp_feedback` in executor reports, `harness: …` notes by the coordinator and main session.
2. `dpcli feedback` groups them into clusters; `harness-retro` decides per cluster whether it is a **project** matter (fixed in the project's `.dpcli-local/`) or a **dpcli** matter.
3. A dpcli fix is made in the `.dpcli` submodule on a branch `improve/<F-n>-<slug>` (`dpcli contribute` makes it from uncommitted edits): a test reproducing the friction first, then the change, `VERSION` +1 and a `CHANGELOG.md` line.
4. `dpcli contribute F-n` opens the pull request — from `origin` if you can push, otherwise from your fork — or, if a similar one is already open, adds a `+1` with your evidence instead of a duplicate.
4. `dpcli contribute F-n` opens the pull request — from `origin` if you can push, otherwise from your fork — or, if a similar one is already open, adds a `+1` with your evidence instead of a duplicate. No fix in `.dpcli` is a refusal; `--issue` reports without one on purpose.

Texts sent here are **anonymized**: no project names, paths, people or quotes — only what hurt, how often, and the proposed change.

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 = 48 # bump on every change: older copies re-exec from the main copy (reexec.maybe_reexec)
VERSION = 49 # bump on every change: older copies re-exec from the main copy (reexec.maybe_reexec)
14 changes: 11 additions & 3 deletions lib/dpcli/commands/contribute.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,12 +37,16 @@ def cmd_contribute(a):
raise DpError(f"{a.id}: no such cluster ({CLI} feedback)")
if c["scope"] != "dpcli":
raise DpError(f"{a.id} is project scope: fix it in .dpcli-local/, not in dpcli")
by_c, _ = assign(items(), {a.id: c})
group = by_c.get(a.id, [])
title = a.title or c["title"]
if any("Ѐ" <= ch <= "ӿ" for ch in title): # Cyrillic; "café" is English enough
raise DpError(f"{a.id}: the hub is public and English, the title is not: --title \"<English title>\"")
dist = reexec.DIST_DIR
if run("git", "rev-parse", "--show-toplevel", cwd=dist).stdout.strip() != str(dist):
raise DpError(f"{dist} is not a git repository (install dpcli as a submodule)")
run("git", "fetch", "-q", "origin", cwd=dist)
branch = run("git", "branch", "--show-current", cwd=dist).stdout.strip()
title = a.title or c["title"]
log = lambda: [x for x in run("git", "log", "--format=%s", "origin/main..HEAD", cwd=dist).stdout.split("\n") if x.strip()]
if log() and not branch.startswith(f"improve/{a.id}-"): # the current branch is pushed: another cluster's otherwise
raise DpError(f"commits on {branch or 'a detached HEAD'} are not {a.id}'s: move them to a branch improve/{a.id}-<slug> "
Expand All @@ -60,8 +64,11 @@ def cmd_contribute(a):
files = run("git", "show", "--name-only", "--format=", "HEAD", cwd=dist).stdout.split()
print(f"{a.id}: committed on {branch}: {' '.join(files)}")
commits = log()
by_c, _ = assign(items(), {a.id: c})
group = by_c.get(a.id, [])
if c["status"] == "fixed" and not commits and not any(i["t"] > c["t_fix"] for i in group): # a real fix may follow a mistaken "fixed"
raise DpError(f"{a.id} is fixed ({c.get('pr') or c.get('where') or 'no link'}), no friction since: nothing to send")
if not commits and not a.issue: # before offline and +1: neither sends a fix
raise DpError(f"{a.id}: no fix in .dpcli — edit it (test, change, VERSION, CHANGELOG), then {CLI} contribute {a.id} "
f"opens a PR; only report it: --issue")
ntasks = len({i["task"] for i in group if i["task"]})
body = (f"**Friction** — {title}\n\nSeen ×{len(group)} in {ntasks} task(s) of one project."
+ (f"\n\n{c['why']}" if c.get("why") else "")
Expand Down Expand Up @@ -182,4 +189,5 @@ def register(sp):
q.add_argument("id", metavar="F-n")
q.add_argument("--title", help="PR/issue title (default: the cluster title; keep it anonymized)")
q.add_argument("--new", action="store_true", help="don't +1 a similar open PR/issue, open a new one")
q.add_argument("--issue", action="store_true", help="no fix in .dpcli: open an issue instead of refusing")
q.set_defaults(func=cmd_contribute)
2 changes: 1 addition & 1 deletion skills/harness-retro/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,6 @@ Cheap: one session, no subagents, only the commands below. Don't read project co
3. **Pick** ≤ 3, most frequent/expensive first: `F-n ×count [scope] → change (file) → gain/cost`.
4. **Apply.**
- project: edit `.dpcli-local/` (`workflow.md`, `claude-md.md`, `agents/<name>.append.md`, `skills/`), `{{cli}} init`, commit `.dpcli-local/ .claude/ CLAUDE.md`, then `{{cli}} feedback fix F-n --where .dpcli-local/<file>`;
- dpcli: in `.dpcli` — a test reproducing the friction, the change, `VERSION` +1, a `CHANGELOG.md` line, then `{{cli}} contribute F-n` — it makes the `improve/F-n-*` branch, the commit and a PR (origin or a fork), or a +1 on a similar open one. If it hurts now, add a temporary workaround in `.dpcli-local/` marked `until: <PR url>`. `dpcli update` reports the verdict later.
- dpcli: in `.dpcli` — a test reproducing the friction, the change, `VERSION` +1, a `CHANGELOG.md` line, then `{{cli}} contribute F-n` — it makes the `improve/F-n-*` branch, the commit and a PR (origin or a fork), or a +1 on a similar open one (no fix: refused; `--issue` reports on purpose). If it hurts now, add a temporary workaround in `.dpcli-local/` marked `until: <PR url>`. `dpcli update` reports the verdict later.
- own PR in rework — fix on its branch, `{{cli}} contribute F-n` again; not worth it — `{{cli}} feedback wontfix F-n --why "…"`.
5. **Record and tell.** `{{cli}} decide harness "<change>" --by main --why "F-n"` per decision; tell the user, a line each.
2 changes: 1 addition & 1 deletion templates/workflow.md
Original file line number Diff line number Diff line change
Expand Up @@ -123,5 +123,5 @@ Every turn pays for the whole session context again.
The process (agents, templates, rules, permissions, dpcli) improves as you work, on its own.
- **Collect — everyone, as you go, one line.** Friction = an extra rework round, waiting for a permission, working around a dpcli command, a rule that hinders or contradicts, wasted tokens/time, a repeated mistake. Executor — the report's `dp_feedback`; reviewer — the review's `harness`; coordinator and main session — `{{cli}} event <m> note --note "harness: <what hurt> → <what to change>"`.
- **Review — main session, skill `harness-retro`:** on `<m> done`, once per phase or on the user's request: `{{cli}} feedback` (clusters, triage project|dpcli), `{{cli}} stats`, ≤ 3 changes applied, then told to the user.
- **Where fixes go.** Project-specific — `.dpcli-local/` (survives updates). dpcli itself — edits in `.dpcli` leave only via `{{cli}} contribute F-n` (branch, commit, a PR to the dpcli hub or a +1 on a similar one); everyone gets it with the next update.
- **Where fixes go.** Project-specific — `.dpcli-local/` (survives updates). dpcli itself — edits in `.dpcli` leave only via `{{cli}} contribute F-n` (branch, commit, a PR to the dpcli hub or a +1 on a similar one; no fix = refusal, `--issue` reports on purpose); everyone gets it with the next update.
- The user's remarks about the work — into memory (feedback) at once, without waiting for a review.
44 changes: 41 additions & 3 deletions tests/test_contribute.py
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,9 @@ def test_pr_from_fork_without_push_rights(self):

def test_plus_one_instead_of_duplicate(self): # nothing to add but the count
similar = json.dumps([{"number": 5, "title": "Long accept blocks agents", "url": "https://github.com/o/r/issues/5"}])
out = self.p.ok("contribute", "F-1", env={**self.env, "FAKE_SEARCH": similar})
self.assertEqual(self.p.run("contribute", "F-1", env={**self.env, "FAKE_SEARCH": similar})[0], 1) # no fix: --issue
self.assertEqual(self.calls(), [])
out = self.p.ok("contribute", "F-1", "--issue", env={**self.env, "FAKE_SEARCH": similar})
self.assertIn("+1", out)
cs = self.calls()
self.assertTrue(any(c[:2] == ["issue", "comment"] and "5" in c for c in cs))
Expand All @@ -106,9 +108,45 @@ def test_fix_for_a_merely_similar_issue_is_a_pr_that_does_not_close_it(self):
self.assertIn("Related: #5", body) # the fix is sent; the merge does not close a merely similar issue
self.assertNotIn("Fixes", body)

def test_issue_without_commits(self):
out = self.p.ok("contribute", "F-1", env=self.env)
def test_issue_without_commits_only_on_request(self):
code, _, err = self.p.run("contribute", "F-1", env=self.env) # no fix in .dpcli: no silent issue
self.assertEqual(code, 1)
self.assertIn("--issue", err)
self.assertFalse(any(c[:2] == ["issue", "create"] for c in self.calls()))
out = self.p.ok("contribute", "F-1", "--issue", env=self.env)
self.assertIn("issues/13", out)
nogh = self.p.tmp / "nogh" # offline: no issue draft either
nogh.mkdir()
(nogh / "gh").write_text("#!/bin/sh\nexit 1\n")
(nogh / "gh").chmod(0o755)
code, _, err = self.p.run("contribute", "F-1", env={**self.env, "PATH": f"{nogh}:{os.environ['PATH']}"})
self.assertEqual(code, 1)
self.assertIn("--issue", err)
self.assertFalse((self.p.root / "build" / "dpcli" / "contribute-F-1").exists())

def test_refuses_a_fixed_cluster_until_it_recurs(self):
self.p.ok("feedback", "fix", "F-1", "--where", "dpcli v2")
code, _, err = self.p.run("contribute", "F-1", "--issue", env=self.env)
self.assertEqual(code, 1)
self.assertIn("fixed", err)
self.assertEqual(self.calls(), [])
ev = {"t": "2099-01-01T12:00:00", "by": "executor", "task": "WB-2", "ev": "reported",
"dp_feedback": "accept --dry still blocks"}
with open(self.p.root / "docs/plan/web/events.jsonl", "a") as f:
f.write(json.dumps(ev) + "\n")
self.assertIn("issues/13", self.p.ok("contribute", "F-1", "--issue", env=self.env))

def test_a_committed_fix_bypasses_the_fixed_refusal(self): # "fixed" was marked by mistake
self.p.ok("feedback", "fix", "F-1", "--where", "dpcli v2")
self.improve()
self.assertIn("pull/12", self.p.ok("contribute", "F-1", env=self.env))

def test_refuses_a_non_english_title(self): # the hub is public and English
code, _, err = self.p.run("contribute", "F-1", "--issue", "--title", "Долгий accept", env=self.env)
self.assertEqual(code, 1)
self.assertIn("--title", err)
self.assertEqual(self.calls(), [])
self.assertIn("issues/13", self.p.ok("contribute", "F-1", "--issue", "--title", "Café-slow accept", env=self.env))

def test_refuses_project_scope_and_main_branch(self):
self.p.ok("feedback", "new", "Local rule", "--match", "zzz", "--scope", "project")
Expand Down
Loading