duet-cli 0.2.20__tar.gz → 0.2.22__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. {duet_cli-0.2.20/duet_cli.egg-info → duet_cli-0.2.22}/PKG-INFO +3 -3
  2. {duet_cli-0.2.20 → duet_cli-0.2.22}/README.md +2 -2
  3. {duet_cli-0.2.20 → duet_cli-0.2.22}/duet.py +12 -5
  4. {duet_cli-0.2.20 → duet_cli-0.2.22/duet_cli.egg-info}/PKG-INFO +3 -3
  5. {duet_cli-0.2.20 → duet_cli-0.2.22}/tests/test_finding_reports.py +21 -2
  6. {duet_cli-0.2.20 → duet_cli-0.2.22}/tests/test_finding_workflow.py +29 -2
  7. {duet_cli-0.2.20 → duet_cli-0.2.22}/LICENSE +0 -0
  8. {duet_cli-0.2.20 → duet_cli-0.2.22}/MANIFEST.in +0 -0
  9. {duet_cli-0.2.20 → duet_cli-0.2.22}/duet_cli.egg-info/SOURCES.txt +0 -0
  10. {duet_cli-0.2.20 → duet_cli-0.2.22}/duet_cli.egg-info/dependency_links.txt +0 -0
  11. {duet_cli-0.2.20 → duet_cli-0.2.22}/duet_cli.egg-info/entry_points.txt +0 -0
  12. {duet_cli-0.2.20 → duet_cli-0.2.22}/duet_cli.egg-info/requires.txt +0 -0
  13. {duet_cli-0.2.20 → duet_cli-0.2.22}/duet_cli.egg-info/top_level.txt +0 -0
  14. {duet_cli-0.2.20 → duet_cli-0.2.22}/packaging/duet-chatgpt/assets/icon.png +0 -0
  15. {duet_cli-0.2.20 → duet_cli-0.2.22}/packaging/duet-chatgpt/openai.json +0 -0
  16. {duet_cli-0.2.20 → duet_cli-0.2.22}/plugins/duet-chatgpt/LICENSE +0 -0
  17. {duet_cli-0.2.20 → duet_cli-0.2.22}/plugins/duet-chatgpt/plugin.json +0 -0
  18. {duet_cli-0.2.20 → duet_cli-0.2.22}/plugins/duet-chatgpt/skills/duet-chatgpt/SKILL.md +0 -0
  19. {duet_cli-0.2.20 → duet_cli-0.2.22}/plugins/duet-chatgpt/skills/duet-chatgpt/references/peer-brief.md +0 -0
  20. {duet_cli-0.2.20 → duet_cli-0.2.22}/plugins/duet-chatgpt/skills/duet-chatgpt/scripts/control.py +0 -0
  21. {duet_cli-0.2.20 → duet_cli-0.2.22}/pyproject.toml +0 -0
  22. {duet_cli-0.2.20 → duet_cli-0.2.22}/scripts/bump_release_version.py +0 -0
  23. {duet_cli-0.2.20 → duet_cli-0.2.22}/scripts/check_distribution_metadata.py +0 -0
  24. {duet_cli-0.2.20 → duet_cli-0.2.22}/scripts/check_sdist_tests.py +0 -0
  25. {duet_cli-0.2.20 → duet_cli-0.2.22}/scripts/duet_loop_e2e.py +0 -0
  26. {duet_cli-0.2.20 → duet_cli-0.2.22}/scripts/package_chatgpt_plugin.py +0 -0
  27. {duet_cli-0.2.20 → duet_cli-0.2.22}/setup.cfg +0 -0
  28. {duet_cli-0.2.20 → duet_cli-0.2.22}/tests/test_backend_metrics.py +0 -0
  29. {duet_cli-0.2.20 → duet_cli-0.2.22}/tests/test_bump_release_version.py +0 -0
  30. {duet_cli-0.2.20 → duet_cli-0.2.22}/tests/test_chatgpt_plugin_control.py +0 -0
  31. {duet_cli-0.2.20 → duet_cli-0.2.22}/tests/test_chatgpt_plugin_package.py +0 -0
  32. {duet_cli-0.2.20 → duet_cli-0.2.22}/tests/test_control_plane.py +0 -0
  33. {duet_cli-0.2.20 → duet_cli-0.2.22}/tests/test_duet.py +0 -0
  34. {duet_cli-0.2.20 → duet_cli-0.2.22}/tests/test_feedback_concurrency.py +0 -0
  35. {duet_cli-0.2.20 → duet_cli-0.2.22}/tests/test_loop_run_info.py +0 -0
  36. {duet_cli-0.2.20 → duet_cli-0.2.22}/tests/test_metrics_cli.py +0 -0
  37. {duet_cli-0.2.20 → duet_cli-0.2.22}/tests/test_metrics_collection.py +0 -0
  38. {duet_cli-0.2.20 → duet_cli-0.2.22}/tests/test_metrics_lifecycle.py +0 -0
  39. {duet_cli-0.2.20 → duet_cli-0.2.22}/tests/test_metrics_reporting.py +0 -0
  40. {duet_cli-0.2.20 → duet_cli-0.2.22}/tests/test_no_subagents.py +0 -0
  41. {duet_cli-0.2.20 → duet_cli-0.2.22}/tests/test_sdist_tests.py +0 -0
  42. {duet_cli-0.2.20 → duet_cli-0.2.22}/tests/test_stop_control_plane.py +0 -0
  43. {duet_cli-0.2.20 → duet_cli-0.2.22}/tests/test_zcode_backend.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: duet-cli
3
- Version: 0.2.20
3
+ Version: 0.2.22
4
4
  Summary: Two CLI agents in conversation. One Python file. Stdlib only.
5
5
  Author: Volkan Altan
6
6
  License-Expression: MIT
@@ -33,9 +33,9 @@ Codex. Gemini and Copilot are also supported. A native ZCode account can
33
33
  provide a [read-only review peer](https://github.com/volkan/duet/blob/main/docs/ZCODE.md).
34
34
  [See examples using one CLI](https://github.com/volkan/duet/blob/main/docs/USAGE.md#same-backend-peering).
35
35
 
36
- [![Watch the 80-second review workflow demo](https://raw.githubusercontent.com/volkan/duet/main/docs/demos/finding-review.png)](https://github.com/volkan/duet/blob/main/docs/demos/finding-review.mp4)
36
+ [![Watch the review demo](https://raw.githubusercontent.com/volkan/duet/main/docs/demos/finding-review.png)](https://volkan.github.io/duet/demos/)
37
37
 
38
- An edited replay using a public toy fixture. [Playback and source](https://github.com/volkan/duet/blob/main/docs/demos/README.md).
38
+ An edited replay using a public toy fixture. [Demo details and evidence](https://github.com/volkan/duet/blob/main/docs/demos/README.md).
39
39
 
40
40
  ## A real example
41
41
 
@@ -12,9 +12,9 @@ Codex. Gemini and Copilot are also supported. A native ZCode account can
12
12
  provide a [read-only review peer](https://github.com/volkan/duet/blob/main/docs/ZCODE.md).
13
13
  [See examples using one CLI](https://github.com/volkan/duet/blob/main/docs/USAGE.md#same-backend-peering).
14
14
 
15
- [![Watch the 80-second review workflow demo](https://raw.githubusercontent.com/volkan/duet/main/docs/demos/finding-review.png)](https://github.com/volkan/duet/blob/main/docs/demos/finding-review.mp4)
15
+ [![Watch the review demo](https://raw.githubusercontent.com/volkan/duet/main/docs/demos/finding-review.png)](https://volkan.github.io/duet/demos/)
16
16
 
17
- An edited replay using a public toy fixture. [Playback and source](https://github.com/volkan/duet/blob/main/docs/demos/README.md).
17
+ An edited replay using a public toy fixture. [Demo details and evidence](https://github.com/volkan/duet/blob/main/docs/demos/README.md).
18
18
 
19
19
  ## A real example
20
20
 
@@ -74,7 +74,7 @@ from typing import Callable, Iterable, Optional
74
74
 
75
75
  # ---------- defaults ----------
76
76
 
77
- __version__ = "0.2.20"
77
+ __version__ = "0.2.22"
78
78
 
79
79
  DEFAULT_SENTINEL = "<<<LGTM>>>"
80
80
  DEFAULT_SANDBOX = "workspace-write"
@@ -6230,7 +6230,7 @@ _RUN_ID_RE = re.compile(r"^\d{8}-\d{6}(?:-\d+)?$")
6230
6230
 
6231
6231
  # ---------- structured finding reports ----------
6232
6232
 
6233
- _FINDING_ID_RE = re.compile(r"\A[LP][1-9][0-9]{0,3}\Z")
6233
+ _FINDING_ID_RE = re.compile(r"\A[A-Za-z0-9][A-Za-z0-9._-]{0,31}\Z")
6234
6234
  _FINDING_BLOCK_MAX_BYTES = 64 * 1024
6235
6235
  _FINDING_EVENT_LIMIT = 1000
6236
6236
  _FINDING_DISPOSITIONS = frozenset({"supported", "refuted", "unresolved"})
@@ -6497,7 +6497,8 @@ def _finding_code(value: object) -> str:
6497
6497
 
6498
6498
 
6499
6499
  def _render_finding(finding: dict) -> str:
6500
- parts = [f"## {finding['id']} — {finding['disposition']} (agent assessment)",
6500
+ # Inline code keeps IDs such as www.example.com or a._b_ from rendering as a link or emphasis.
6501
+ parts = [f"## `{finding['id']}` — {finding['disposition']} (agent assessment)",
6501
6502
  _finding_code(finding["claim"])]
6502
6503
  for event in finding["assessments"]:
6503
6504
  origin = "inherited" if event["inherited"] else "current run"
@@ -7721,8 +7722,14 @@ def _finding_prompt_addendum(agent: Agent, cfg: DuetConfig) -> str:
7721
7722
  '"disposition":"supported|refuted|unresolved","evidence":["file:line or '
7722
7723
  'concrete check and result"],"objection":"remaining concern or null"}. '
7723
7724
  "Use actual enum values, not the pipe-separated alternatives. Preserve IDs "
7724
- "from the prior conversation; allocate new IDs with your prefix " + prefix +
7725
- " and an unused positive integer. Reassess disputed findings explicitly. "
7725
+ "from the prior conversation. A valid ID has 1 to 32 ASCII characters: it "
7726
+ "starts with a letter or digit, then uses letters, digits, '.', '_', or '-'. "
7727
+ "When the task, the kickoff, or the other agent already labels a claim with a "
7728
+ "valid ID, reuse that exact ID, including its case. Otherwise allocate a new "
7729
+ "ID with your prefix " + prefix + " and an unused positive integer. Never "
7730
+ "copy a label that is not a valid ID, such as '#1' or 'Finding 3': allocate "
7731
+ "a new ID and name that label in the claim. Reassess disputed findings "
7732
+ "explicitly. "
7726
7733
  "Use unresolved when evidence is missing. Cite only evidence you inspected; "
7727
7734
  "never invent executed checks or human acceptance. An empty findings array "
7728
7735
  "means no finding updates, not proof that the review is correct. Keep recap "
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: duet-cli
3
- Version: 0.2.20
3
+ Version: 0.2.22
4
4
  Summary: Two CLI agents in conversation. One Python file. Stdlib only.
5
5
  Author: Volkan Altan
6
6
  License-Expression: MIT
@@ -33,9 +33,9 @@ Codex. Gemini and Copilot are also supported. A native ZCode account can
33
33
  provide a [read-only review peer](https://github.com/volkan/duet/blob/main/docs/ZCODE.md).
34
34
  [See examples using one CLI](https://github.com/volkan/duet/blob/main/docs/USAGE.md#same-backend-peering).
35
35
 
36
- [![Watch the 80-second review workflow demo](https://raw.githubusercontent.com/volkan/duet/main/docs/demos/finding-review.png)](https://github.com/volkan/duet/blob/main/docs/demos/finding-review.mp4)
36
+ [![Watch the review demo](https://raw.githubusercontent.com/volkan/duet/main/docs/demos/finding-review.png)](https://volkan.github.io/duet/demos/)
37
37
 
38
- An edited replay using a public toy fixture. [Playback and source](https://github.com/volkan/duet/blob/main/docs/demos/README.md).
38
+ An edited replay using a public toy fixture. [Demo details and evidence](https://github.com/volkan/duet/blob/main/docs/demos/README.md).
39
39
 
40
40
  ## A real example
41
41
 
@@ -47,7 +47,7 @@ class TestFindingParser(unittest.TestCase):
47
47
  def test_duplicate_blocks_unclosed_json_and_duplicate_ids_reject_atomically(self):
48
48
  for reply in (block([]) + "\n" + block([]), "```duet-findings\n{}",
49
49
  "```duet-findings\nnot JSON\n```", block([item(), item()]),
50
- block([item(), item("bad")])):
50
+ block([item(), item("bad id")])):
51
51
  with self.subTest(reply=reply):
52
52
  self.assertEqual(duet.parse_finding_updates(reply), {"status": "malformed", "items": []})
53
53
 
@@ -56,8 +56,18 @@ class TestFindingParser(unittest.TestCase):
56
56
  self.assertEqual(duet.parse_finding_updates(reply)["status"], "missing")
57
57
  self.assertEqual(duet.parse_finding_updates(reply + "\n" + block([]))["status"], "ok")
58
58
 
59
+ def test_caller_defined_ids_are_accepted_and_bad_ids_rejected(self):
60
+ valid = ["F1", "C1", "P1-1", "a.b_c-d", "9", "L" + "x" * 31]
61
+ parsed = duet.parse_finding_updates(block([item(identity) for identity in valid]))
62
+ self.assertEqual([entry["id"] for entry in parsed["items"]], valid)
63
+ invalid = ["", "F 1", "L" + "x" * 32, "-F1", ".F1", "\u00c9\u00c91", "F1\n",
64
+ "_F1", "#1", "Finding 3"]
65
+ for identity in invalid:
66
+ with self.subTest(identity=identity):
67
+ self.assertEqual(duet.parse_finding_updates(block([item(identity)]))["status"], "malformed")
68
+
59
69
  def test_field_limits_and_types(self):
60
- invalid = [item("L0"), item("L10000"), item("P01"), item(claim=""),
70
+ invalid = [item(claim=""),
61
71
  item(claim="x" * 2001), item(disposition=[]), item(evidence="test"),
62
72
  item(evidence=["x"] * 9), item(evidence=["x" * 2001]),
63
73
  item(objection="x" * 2001), item(claim="\ud800")]
@@ -165,6 +175,15 @@ class TestFindingRendering(unittest.TestCase):
165
175
  self.assertIn("private", rendered)
166
176
  self.assertIn("not proven facts", rendered)
167
177
 
178
+ def test_heading_shows_id_as_inline_code(self):
179
+ for identity in ["L1", "bug_12", "F1._note_", "www.example.com"]:
180
+ with self.subTest(identity=identity):
181
+ report = duet.build_finding_report(state([turn(1, "reviewer", [item(identity)])]))
182
+ rendered = duet.render_finding_report(report)
183
+ self.assertIn(f"## `{identity}` \u2014 unresolved (agent assessment)\n", rendered)
184
+ self.assertEqual(report["findings"][0]["id"], identity)
185
+ self.assertIn(f"--resolve {identity} --turns 2", rendered)
186
+
168
187
 
169
188
  if __name__ == "__main__":
170
189
  unittest.main()
@@ -137,18 +137,35 @@ class TestFindingWorkflow(unittest.TestCase):
137
137
  self.assertNotIn("{SENTINEL}", prompt)
138
138
  self.assertIn("duet-findings", prompt)
139
139
  self.assertIn('"id":"L1"', prompt)
140
+ self.assertIn("1 to 32 ASCII characters", prompt)
141
+ self.assertIn("letters, digits, '.', '_', or '-'", prompt)
142
+ self.assertIn("starts with a letter or digit", prompt)
143
+ self.assertIn("reuse that exact ID, including its case", prompt)
144
+ self.assertIn("Never copy a label that is not a valid ID", prompt)
140
145
 
141
146
  def test_successful_turns_persist_updates_and_rebuild_local_report(self) -> None:
142
147
  state = self._run([_reply("supported"), _reply("supported")])
143
148
  run = pathlib.Path(state["transcript_path"]).parent
144
149
  saved = json.loads((run / "state.json").read_text(encoding="utf-8"))
145
150
  self.assertEqual([entry["finding_updates"]["status"] for entry in saved["history"]], ["ok", "ok"])
146
- self.assertIn("L1 — supported", (run / "review.md").read_text(encoding="utf-8"))
151
+ self.assertIn("`L1` — supported", (run / "review.md").read_text(encoding="utf-8"))
147
152
  (run / "review.md").unlink()
148
153
  duet._write_run_state(run / "state.json", saved)
149
154
  self.assertEqual((run / "review.md").read_text(encoding="utf-8"),
150
155
  duet.render_finding_report(duet.build_finding_report(saved)))
151
156
 
157
+ def test_caller_defined_ids_differing_by_case_stay_separate_through_a_run(self) -> None:
158
+ def reply(identity: str) -> str:
159
+ return "```duet-findings\n" + json.dumps(
160
+ {"findings": [_assessment(identity=identity)]}) + "\n```"
161
+ state = self._run([reply("F1"), reply("f1")])
162
+ stored = [entry["finding_updates"]["items"][0]["id"] for entry in state["history"]]
163
+ self.assertEqual(stored, ["F1", "f1"])
164
+ run = pathlib.Path(state["transcript_path"]).parent
165
+ report = (run / "review.md").read_text(encoding="utf-8")
166
+ self.assertIn("## `F1` — ", report)
167
+ self.assertIn("## `f1` — ", report)
168
+
152
169
  def test_failed_reply_cannot_publish_structured_claims_from_error_text(self) -> None:
153
170
  state = self._run([duet.AgentRunError("agent_error", _reply("supported"))])
154
171
  self.assertNotIn("finding_updates", state["history"][0])
@@ -173,7 +190,7 @@ class TestFindingWorkflow(unittest.TestCase):
173
190
  self.assertFalse((run / "review.md").exists())
174
191
  code, out, err = self._cli("--report", str(run))
175
192
  self.assertEqual(code, 0, err)
176
- self.assertIn("L1 — supported", out)
193
+ self.assertIn("`L1` — supported", out)
177
194
  self.assertFalse((run / "review.md").exists())
178
195
 
179
196
  def test_forced_success_and_failure_follow_same_update_contract(self) -> None:
@@ -235,6 +252,16 @@ class TestFindingWorkflow(unittest.TestCase):
235
252
  self.assertEqual([finding["id"] for finding in report["findings"]], ["L1"])
236
253
  self.assertEqual(report["findings"][0]["disposition"], "refuted")
237
254
 
255
+ def test_resolve_selects_caller_defined_id(self) -> None:
256
+ history = [{"turn": turn, "agent": agent, "finding_updates": {
257
+ "status": "ok", "items": [_assessment(identity="P1-1", disposition=disposition)]}}
258
+ for turn, agent, disposition in ((1, "lead", "supported"), (2, "partner", "unresolved"))]
259
+ run = self._saved_run("caller-id", history=history, turns_used=2)
260
+ cfg = self._continue(run, "--resolve", "P1-1")
261
+ self.assertEqual(cfg.resolve_findings, ["P1-1"])
262
+ self.assertEqual([event["id"] for event in cfg.finding_baseline], ["P1-1", "P1-1"])
263
+ self.assertIn("P1-1", cfg.kickoff)
264
+
238
265
  def test_resolve_rejects_unknown_resolved_ids_and_short_budget(self) -> None:
239
266
  open_run = self._saved_run("open", history=self._history())
240
267
  closed_run = self._saved_run("closed", history=self._history(resolved=True))
File without changes
File without changes
File without changes
File without changes
File without changes