drydock-cli 3.0.129__tar.gz → 3.0.130__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 (141) hide show
  1. {drydock_cli-3.0.129/drydock_cli.egg-info → drydock_cli-3.0.130}/PKG-INFO +1 -1
  2. {drydock_cli-3.0.129 → drydock_cli-3.0.130}/drydock/agent.py +7 -0
  3. {drydock_cli-3.0.129 → drydock_cli-3.0.130}/drydock/task_state.py +58 -0
  4. {drydock_cli-3.0.129 → drydock_cli-3.0.130/drydock_cli.egg-info}/PKG-INFO +1 -1
  5. {drydock_cli-3.0.129 → drydock_cli-3.0.130}/drydock_cli.egg-info/SOURCES.txt +1 -0
  6. {drydock_cli-3.0.129 → drydock_cli-3.0.130}/pyproject.toml +1 -1
  7. drydock_cli-3.0.130/tests/test_rolling_plan.py +49 -0
  8. {drydock_cli-3.0.129 → drydock_cli-3.0.130}/LICENSE +0 -0
  9. {drydock_cli-3.0.129 → drydock_cli-3.0.130}/NOTICE +0 -0
  10. {drydock_cli-3.0.129 → drydock_cli-3.0.130}/README.md +0 -0
  11. {drydock_cli-3.0.129 → drydock_cli-3.0.130}/drydock/__init__.py +0 -0
  12. {drydock_cli-3.0.129 → drydock_cli-3.0.130}/drydock/__main__.py +0 -0
  13. {drydock_cli-3.0.129 → drydock_cli-3.0.130}/drydock/advisor.py +0 -0
  14. {drydock_cli-3.0.129 → drydock_cli-3.0.130}/drydock/bash_safety.py +0 -0
  15. {drydock_cli-3.0.129 → drydock_cli-3.0.130}/drydock/builtin_skills/__init__.py +0 -0
  16. {drydock_cli-3.0.129 → drydock_cli-3.0.130}/drydock/builtin_skills/ml-data.md +0 -0
  17. {drydock_cli-3.0.129 → drydock_cli-3.0.130}/drydock/builtin_skills/ml-debug.md +0 -0
  18. {drydock_cli-3.0.129 → drydock_cli-3.0.130}/drydock/builtin_skills/ml-finetune.md +0 -0
  19. {drydock_cli-3.0.129 → drydock_cli-3.0.130}/drydock/builtin_skills/ml-metrics.md +0 -0
  20. {drydock_cli-3.0.129 → drydock_cli-3.0.130}/drydock/builtin_skills/ml-rl.md +0 -0
  21. {drydock_cli-3.0.129 → drydock_cli-3.0.130}/drydock/builtin_skills/ml-train.md +0 -0
  22. {drydock_cli-3.0.129 → drydock_cli-3.0.130}/drydock/builtin_skills/rmf-categorize.md +0 -0
  23. {drydock_cli-3.0.129 → drydock_cli-3.0.130}/drydock/builtin_skills/rmf-control.md +0 -0
  24. {drydock_cli-3.0.129 → drydock_cli-3.0.130}/drydock/builtin_skills/rmf-poam.md +0 -0
  25. {drydock_cli-3.0.129 → drydock_cli-3.0.130}/drydock/builtin_skills/rmf-review.md +0 -0
  26. {drydock_cli-3.0.129 → drydock_cli-3.0.130}/drydock/builtin_skills/stig-assess.md +0 -0
  27. {drydock_cli-3.0.129 → drydock_cli-3.0.130}/drydock/builtin_skills/stig-remediate.md +0 -0
  28. {drydock_cli-3.0.129 → drydock_cli-3.0.130}/drydock/cci.py +0 -0
  29. {drydock_cli-3.0.129 → drydock_cli-3.0.130}/drydock/cli.py +0 -0
  30. {drydock_cli-3.0.129 → drydock_cli-3.0.130}/drydock/compaction.py +0 -0
  31. {drydock_cli-3.0.129 → drydock_cli-3.0.130}/drydock/config.py +0 -0
  32. {drydock_cli-3.0.129 → drydock_cli-3.0.130}/drydock/detect.py +0 -0
  33. {drydock_cli-3.0.129 → drydock_cli-3.0.130}/drydock/events.py +0 -0
  34. {drydock_cli-3.0.129 → drydock_cli-3.0.130}/drydock/extract.py +0 -0
  35. {drydock_cli-3.0.129 → drydock_cli-3.0.130}/drydock/gittools.py +0 -0
  36. {drydock_cli-3.0.129 → drydock_cli-3.0.130}/drydock/graphrag.py +0 -0
  37. {drydock_cli-3.0.129 → drydock_cli-3.0.130}/drydock/guards.py +0 -0
  38. {drydock_cli-3.0.129 → drydock_cli-3.0.130}/drydock/loop_detect.py +0 -0
  39. {drydock_cli-3.0.129 → drydock_cli-3.0.130}/drydock/mcp.py +0 -0
  40. {drydock_cli-3.0.129 → drydock_cli-3.0.130}/drydock/poam.py +0 -0
  41. {drydock_cli-3.0.129 → drydock_cli-3.0.130}/drydock/providers.py +0 -0
  42. {drydock_cli-3.0.129 → drydock_cli-3.0.130}/drydock/recipes.py +0 -0
  43. {drydock_cli-3.0.129 → drydock_cli-3.0.130}/drydock/rmf.py +0 -0
  44. {drydock_cli-3.0.129 → drydock_cli-3.0.130}/drydock/rmf_graph.py +0 -0
  45. {drydock_cli-3.0.129 → drydock_cli-3.0.130}/drydock/skills.py +0 -0
  46. {drydock_cli-3.0.129 → drydock_cli-3.0.130}/drydock/stig.py +0 -0
  47. {drydock_cli-3.0.129 → drydock_cli-3.0.130}/drydock/suggest.py +0 -0
  48. {drydock_cli-3.0.129 → drydock_cli-3.0.130}/drydock/tool_registry.py +0 -0
  49. {drydock_cli-3.0.129 → drydock_cli-3.0.130}/drydock/tools/__init__.py +0 -0
  50. {drydock_cli-3.0.129 → drydock_cli-3.0.130}/drydock/tui/__init__.py +0 -0
  51. {drydock_cli-3.0.129 → drydock_cli-3.0.130}/drydock/tui/app.py +0 -0
  52. {drydock_cli-3.0.129 → drydock_cli-3.0.130}/drydock/tui/approval.py +0 -0
  53. {drydock_cli-3.0.129 → drydock_cli-3.0.130}/drydock/tui/messages.py +0 -0
  54. {drydock_cli-3.0.129 → drydock_cli-3.0.130}/drydock/tui/widgets.py +0 -0
  55. {drydock_cli-3.0.129 → drydock_cli-3.0.130}/drydock/tuning.py +0 -0
  56. {drydock_cli-3.0.129 → drydock_cli-3.0.130}/drydock/verification.py +0 -0
  57. {drydock_cli-3.0.129 → drydock_cli-3.0.130}/drydock/web.py +0 -0
  58. {drydock_cli-3.0.129 → drydock_cli-3.0.130}/drydock_cli.egg-info/dependency_links.txt +0 -0
  59. {drydock_cli-3.0.129 → drydock_cli-3.0.130}/drydock_cli.egg-info/entry_points.txt +0 -0
  60. {drydock_cli-3.0.129 → drydock_cli-3.0.130}/drydock_cli.egg-info/requires.txt +0 -0
  61. {drydock_cli-3.0.129 → drydock_cli-3.0.130}/drydock_cli.egg-info/top_level.txt +0 -0
  62. {drydock_cli-3.0.129 → drydock_cli-3.0.130}/setup.cfg +0 -0
  63. {drydock_cli-3.0.129 → drydock_cli-3.0.130}/tests/test_advisor.py +0 -0
  64. {drydock_cli-3.0.129 → drydock_cli-3.0.130}/tests/test_approval.py +0 -0
  65. {drydock_cli-3.0.129 → drydock_cli-3.0.130}/tests/test_back_command.py +0 -0
  66. {drydock_cli-3.0.129 → drydock_cli-3.0.130}/tests/test_bash_background.py +0 -0
  67. {drydock_cli-3.0.129 → drydock_cli-3.0.130}/tests/test_bash_binary_output.py +0 -0
  68. {drydock_cli-3.0.129 → drydock_cli-3.0.130}/tests/test_bash_crossplatform.py +0 -0
  69. {drydock_cli-3.0.129 → drydock_cli-3.0.130}/tests/test_bash_output_bounding.py +0 -0
  70. {drydock_cli-3.0.129 → drydock_cli-3.0.130}/tests/test_bash_process_group.py +0 -0
  71. {drydock_cli-3.0.129 → drydock_cli-3.0.130}/tests/test_bash_safety.py +0 -0
  72. {drydock_cli-3.0.129 → drydock_cli-3.0.130}/tests/test_bash_sanitize.py +0 -0
  73. {drydock_cli-3.0.129 → drydock_cli-3.0.130}/tests/test_bash_shell.py +0 -0
  74. {drydock_cli-3.0.129 → drydock_cli-3.0.130}/tests/test_bash_stdin.py +0 -0
  75. {drydock_cli-3.0.129 → drydock_cli-3.0.130}/tests/test_bash_stop_partial.py +0 -0
  76. {drydock_cli-3.0.129 → drydock_cli-3.0.130}/tests/test_bash_timeout_network.py +0 -0
  77. {drydock_cli-3.0.129 → drydock_cli-3.0.130}/tests/test_bash_timeout_param.py +0 -0
  78. {drydock_cli-3.0.129 → drydock_cli-3.0.130}/tests/test_cci.py +0 -0
  79. {drydock_cli-3.0.129 → drydock_cli-3.0.130}/tests/test_cli_agents.py +0 -0
  80. {drydock_cli-3.0.129 → drydock_cli-3.0.130}/tests/test_compact_command.py +0 -0
  81. {drydock_cli-3.0.129 → drydock_cli-3.0.130}/tests/test_compaction.py +0 -0
  82. {drydock_cli-3.0.129 → drydock_cli-3.0.130}/tests/test_config.py +0 -0
  83. {drydock_cli-3.0.129 → drydock_cli-3.0.130}/tests/test_config_migration.py +0 -0
  84. {drydock_cli-3.0.129 → drydock_cli-3.0.130}/tests/test_context_limit_config.py +0 -0
  85. {drydock_cli-3.0.129 → drydock_cli-3.0.130}/tests/test_detect.py +0 -0
  86. {drydock_cli-3.0.129 → drydock_cli-3.0.130}/tests/test_dispatch.py +0 -0
  87. {drydock_cli-3.0.129 → drydock_cli-3.0.130}/tests/test_e2e_connected.py +0 -0
  88. {drydock_cli-3.0.129 → drydock_cli-3.0.130}/tests/test_edit_replace_all.py +0 -0
  89. {drydock_cli-3.0.129 → drydock_cli-3.0.130}/tests/test_empty_response.py +0 -0
  90. {drydock_cli-3.0.129 → drydock_cli-3.0.130}/tests/test_events.py +0 -0
  91. {drydock_cli-3.0.129 → drydock_cli-3.0.130}/tests/test_extract.py +0 -0
  92. {drydock_cli-3.0.129 → drydock_cli-3.0.130}/tests/test_failure_loop.py +0 -0
  93. {drydock_cli-3.0.129 → drydock_cli-3.0.130}/tests/test_first_run_setup.py +0 -0
  94. {drydock_cli-3.0.129 → drydock_cli-3.0.130}/tests/test_gittools.py +0 -0
  95. {drydock_cli-3.0.129 → drydock_cli-3.0.130}/tests/test_glob_edit_edges.py +0 -0
  96. {drydock_cli-3.0.129 → drydock_cli-3.0.130}/tests/test_graphify_example.py +0 -0
  97. {drydock_cli-3.0.129 → drydock_cli-3.0.130}/tests/test_graphrag.py +0 -0
  98. {drydock_cli-3.0.129 → drydock_cli-3.0.130}/tests/test_graphrag_quoted_path.py +0 -0
  99. {drydock_cli-3.0.129 → drydock_cli-3.0.130}/tests/test_grep_and_read_robust.py +0 -0
  100. {drydock_cli-3.0.129 → drydock_cli-3.0.130}/tests/test_guards_and_tools.py +0 -0
  101. {drydock_cli-3.0.129 → drydock_cli-3.0.130}/tests/test_hallucinated_tools.py +0 -0
  102. {drydock_cli-3.0.129 → drydock_cli-3.0.130}/tests/test_leaked_tool_call.py +0 -0
  103. {drydock_cli-3.0.129 → drydock_cli-3.0.130}/tests/test_loop_detect.py +0 -0
  104. {drydock_cli-3.0.129 → drydock_cli-3.0.130}/tests/test_mcp.py +0 -0
  105. {drydock_cli-3.0.129 → drydock_cli-3.0.130}/tests/test_oneshot_unreachable.py +0 -0
  106. {drydock_cli-3.0.129 → drydock_cli-3.0.130}/tests/test_overthink_interrupt.py +0 -0
  107. {drydock_cli-3.0.129 → drydock_cli-3.0.130}/tests/test_plan_autocontinue.py +0 -0
  108. {drydock_cli-3.0.129 → drydock_cli-3.0.130}/tests/test_poam.py +0 -0
  109. {drydock_cli-3.0.129 → drydock_cli-3.0.130}/tests/test_providers_unreachable.py +0 -0
  110. {drydock_cli-3.0.129 → drydock_cli-3.0.130}/tests/test_read_index.py +0 -0
  111. {drydock_cli-3.0.129 → drydock_cli-3.0.130}/tests/test_recipes.py +0 -0
  112. {drydock_cli-3.0.129 → drydock_cli-3.0.130}/tests/test_repetition_interrupt.py +0 -0
  113. {drydock_cli-3.0.129 → drydock_cli-3.0.130}/tests/test_rmf.py +0 -0
  114. {drydock_cli-3.0.129 → drydock_cli-3.0.130}/tests/test_rmf_graph.py +0 -0
  115. {drydock_cli-3.0.129 → drydock_cli-3.0.130}/tests/test_rmf_stig_graph.py +0 -0
  116. {drydock_cli-3.0.129 → drydock_cli-3.0.130}/tests/test_runaway_repetition.py +0 -0
  117. {drydock_cli-3.0.129 → drydock_cli-3.0.130}/tests/test_screenshot.py +0 -0
  118. {drydock_cli-3.0.129 → drydock_cli-3.0.130}/tests/test_server_probe.py +0 -0
  119. {drydock_cli-3.0.129 → drydock_cli-3.0.130}/tests/test_skills.py +0 -0
  120. {drydock_cli-3.0.129 → drydock_cli-3.0.130}/tests/test_stall_retry.py +0 -0
  121. {drydock_cli-3.0.129 → drydock_cli-3.0.130}/tests/test_stig.py +0 -0
  122. {drydock_cli-3.0.129 → drydock_cli-3.0.130}/tests/test_stop.py +0 -0
  123. {drydock_cli-3.0.129 → drydock_cli-3.0.130}/tests/test_streaming_newlines.py +0 -0
  124. {drydock_cli-3.0.129 → drydock_cli-3.0.130}/tests/test_subagent.py +0 -0
  125. {drydock_cli-3.0.129 → drydock_cli-3.0.130}/tests/test_suggest.py +0 -0
  126. {drydock_cli-3.0.129 → drydock_cli-3.0.130}/tests/test_system_prompt_help.py +0 -0
  127. {drydock_cli-3.0.129 → drydock_cli-3.0.130}/tests/test_task_state.py +0 -0
  128. {drydock_cli-3.0.129 → drydock_cli-3.0.130}/tests/test_todo.py +0 -0
  129. {drydock_cli-3.0.129 → drydock_cli-3.0.130}/tests/test_tool_arg_coercion.py +0 -0
  130. {drydock_cli-3.0.129 → drydock_cli-3.0.130}/tests/test_tool_arg_coercion_more.py +0 -0
  131. {drydock_cli-3.0.129 → drydock_cli-3.0.130}/tests/test_tool_arg_parsing.py +0 -0
  132. {drydock_cli-3.0.129 → drydock_cli-3.0.130}/tests/test_tools_undo.py +0 -0
  133. {drydock_cli-3.0.129 → drydock_cli-3.0.130}/tests/test_tui.py +0 -0
  134. {drydock_cli-3.0.129 → drydock_cli-3.0.130}/tests/test_tuning.py +0 -0
  135. {drydock_cli-3.0.129 → drydock_cli-3.0.130}/tests/test_verification_gate.py +0 -0
  136. {drydock_cli-3.0.129 → drydock_cli-3.0.130}/tests/test_viewimage.py +0 -0
  137. {drydock_cli-3.0.129 → drydock_cli-3.0.130}/tests/test_vision_input.py +0 -0
  138. {drydock_cli-3.0.129 → drydock_cli-3.0.130}/tests/test_web_tools.py +0 -0
  139. {drydock_cli-3.0.129 → drydock_cli-3.0.130}/tests/test_windows_shell.py +0 -0
  140. {drydock_cli-3.0.129 → drydock_cli-3.0.130}/tests/test_write_content_coerce.py +0 -0
  141. {drydock_cli-3.0.129 → drydock_cli-3.0.130}/tests/test_xccdf.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: drydock-cli
3
- Version: 3.0.129
3
+ Version: 3.0.130
4
4
  Summary: Drydock — a local, provider-agnostic terminal coding agent for local LLMs
5
5
  Author: Frank Bobe III
6
6
  License-Expression: Apache-2.0
@@ -452,6 +452,13 @@ def run(
452
452
  _emit(state, "verification", status=last_verification.status,
453
453
  exit_code=last_verification.exit_code)
454
454
 
455
+ # Sync the rolling plan when the model updates its checklist, keeping
456
+ # stable step ids + capping pending steps; record each revision.
457
+ if tc["name"] == "todo" and isinstance(config.get("_todo"), list):
458
+ if state.task.plan.update_from_items(config["_todo"]):
459
+ _emit(state, "plan", version=state.task.plan.version,
460
+ steps=[(s["id"], s["status"]) for s in state.task.plan.steps])
461
+
455
462
  _emit(state, "tool", name=tc["name"],
456
463
  input=str(tc.get("input"))[:200],
457
464
  result_chars=len(str(result)),
@@ -40,12 +40,64 @@ def extract_acceptance_criteria(objective: str, limit: int = 10) -> list[str]:
40
40
  return out[:limit]
41
41
 
42
42
 
43
+ def _step_id(text: str) -> str:
44
+ """Stable short id for a plan step, derived from its text (so the SAME step
45
+ keeps its id across plan revisions). Deterministic — no randomness/time."""
46
+ h = 0
47
+ for ch in text.strip().lower():
48
+ h = (h * 131 + ord(ch)) & 0xFFFFFFFF
49
+ return f"s{h:08x}"
50
+
51
+
52
+ @dataclass
53
+ class RollingPlan:
54
+ """A short, updateable plan (PRD Epic C): one ACTIVE step + a few pending ones,
55
+ each with a stable id. Rebuilt from the checklist the model maintains; ids are
56
+ text-derived so an unchanged step keeps its id across revisions."""
57
+ steps: list[dict] = field(default_factory=list) # {id, text, status}
58
+ version: int = 0
59
+ max_pending: int = 4
60
+
61
+ def update_from_items(self, items: list[tuple[str, str]]) -> bool:
62
+ """Sync from (text, status) checklist pairs. Caps pending steps (keeps the
63
+ active + first max_pending pending). Returns True if the plan CHANGED."""
64
+ steps = [{"id": _step_id(t), "text": t, "status": s} for t, s in items if t]
65
+ # active = first in_progress, else first pending
66
+ active = next((s for s in steps if s["status"] == "in_progress"), None)
67
+ if active is None:
68
+ active = next((s for s in steps if s["status"] == "pending"), None)
69
+ kept, pending_kept = [], 0
70
+ for s in steps:
71
+ if s["status"] == "done" or s is active:
72
+ kept.append(s)
73
+ elif s["status"] == "pending" and pending_kept < self.max_pending:
74
+ kept.append(s); pending_kept += 1
75
+ elif s["status"] == "in_progress":
76
+ kept.append(s)
77
+ changed = [(s["id"], s["status"]) for s in kept] != [(s["id"], s["status"]) for s in self.steps]
78
+ if changed:
79
+ self.steps = kept
80
+ self.version += 1
81
+ return changed
82
+
83
+ def active_step(self) -> dict | None:
84
+ return next((s for s in self.steps if s["status"] == "in_progress"), None) \
85
+ or next((s for s in self.steps if s["status"] == "pending"), None)
86
+
87
+ def remaining(self) -> int:
88
+ return sum(1 for s in self.steps if s["status"] != "done")
89
+
90
+ def to_dict(self) -> dict:
91
+ return {"steps": list(self.steps), "version": self.version}
92
+
93
+
43
94
  @dataclass
44
95
  class TaskState:
45
96
  """Authoritative task facts, independent of the message transcript."""
46
97
  objective: str = ""
47
98
  acceptance_criteria: list[str] = field(default_factory=list)
48
99
  phase: str = "understand"
100
+ plan: RollingPlan = field(default_factory=RollingPlan)
49
101
 
50
102
  @classmethod
51
103
  def from_objective(cls, objective: str) -> TaskState:
@@ -61,14 +113,20 @@ class TaskState:
61
113
  "objective": self.objective,
62
114
  "acceptance_criteria": list(self.acceptance_criteria),
63
115
  "phase": self.phase,
116
+ "plan": self.plan.to_dict(),
64
117
  }
65
118
 
66
119
  @classmethod
67
120
  def from_dict(cls, d: dict) -> TaskState:
121
+ plan = RollingPlan()
122
+ pd = d.get("plan") or {}
123
+ plan.steps = list(pd.get("steps", []))
124
+ plan.version = pd.get("version", 0)
68
125
  return cls(
69
126
  objective=d.get("objective", ""),
70
127
  acceptance_criteria=list(d.get("acceptance_criteria", [])),
71
128
  phase=d.get("phase", "understand"),
129
+ plan=plan,
72
130
  )
73
131
 
74
132
  def anchor_text(self) -> str:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: drydock-cli
3
- Version: 3.0.129
3
+ Version: 3.0.130
4
4
  Summary: Drydock — a local, provider-agnostic terminal coding agent for local LLMs
5
5
  Author: Frank Bobe III
6
6
  License-Expression: Apache-2.0
@@ -110,6 +110,7 @@ tests/test_repetition_interrupt.py
110
110
  tests/test_rmf.py
111
111
  tests/test_rmf_graph.py
112
112
  tests/test_rmf_stig_graph.py
113
+ tests/test_rolling_plan.py
113
114
  tests/test_runaway_repetition.py
114
115
  tests/test_screenshot.py
115
116
  tests/test_server_probe.py
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
7
7
  # PyPI distribution name is drydock-cli (the established install name, continued
8
8
  # from the retired v2 fork); the import package + CLI command stay `drydock`.
9
9
  name = "drydock-cli"
10
- version = "3.0.129"
10
+ version = "3.0.130"
11
11
  description = "Drydock — a local, provider-agnostic terminal coding agent for local LLMs"
12
12
  readme = "README.md"
13
13
  requires-python = ">=3.11"
@@ -0,0 +1,49 @@
1
+ """Rolling plan (Agent-Buildout PRD Epic C): 1 active + <=4 pending steps with
2
+ stable ids; revisions bump the version; unchanged steps keep their id."""
3
+ from __future__ import annotations
4
+
5
+ from drydock.task_state import RollingPlan, TaskState, _step_id
6
+
7
+
8
+ def test_stable_ids_survive_revision():
9
+ p = RollingPlan()
10
+ p.update_from_items([("read file", "done"), ("fix bug", "in_progress"), ("test", "pending")])
11
+ ids1 = {s["text"]: s["id"] for s in p.steps}
12
+ # revise: fix bug now done, test now active
13
+ p.update_from_items([("read file", "done"), ("fix bug", "done"), ("test", "in_progress")])
14
+ ids2 = {s["text"]: s["id"] for s in p.steps}
15
+ assert ids1["fix bug"] == ids2["fix bug"] # same step, same id
16
+ assert ids1["test"] == ids2["test"]
17
+
18
+
19
+ def test_caps_pending_to_four_plus_active():
20
+ p = RollingPlan()
21
+ items = [("active", "in_progress")] + [(f"p{i}", "pending") for i in range(8)]
22
+ p.update_from_items(items)
23
+ pend = [s for s in p.steps if s["status"] == "pending"]
24
+ assert len(pend) == 4 and any(s["status"] == "in_progress" for s in p.steps)
25
+
26
+
27
+ def test_version_bumps_only_on_change():
28
+ p = RollingPlan()
29
+ assert p.update_from_items([("a", "pending")]) and p.version == 1
30
+ assert not p.update_from_items([("a", "pending")]) and p.version == 1 # no change
31
+ assert p.update_from_items([("a", "done")]) and p.version == 2
32
+
33
+
34
+ def test_active_and_remaining():
35
+ p = RollingPlan()
36
+ p.update_from_items([("a", "done"), ("b", "in_progress"), ("c", "pending")])
37
+ assert p.active_step()["text"] == "b" and p.remaining() == 2
38
+
39
+
40
+ def test_serialization_roundtrip():
41
+ ts = TaskState.from_objective("Do it.\n1. a\n2. b")
42
+ ts.plan.update_from_items([("a", "done"), ("b", "in_progress")])
43
+ d = ts.to_dict()
44
+ assert d["plan"]["version"] == 1 and len(d["plan"]["steps"]) == 2
45
+ assert TaskState.from_dict(d).plan.active_step()["text"] == "b"
46
+
47
+
48
+ def test_step_id_deterministic():
49
+ assert _step_id("fix the bug") == _step_id("Fix The Bug ") # case/space-insensitive
File without changes
File without changes
File without changes
File without changes