modus-operandi 0.2.0__tar.gz → 0.2.2__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.

Potentially problematic release.


This version of modus-operandi might be problematic. Click here for more details.

Files changed (113) hide show
  1. {modus_operandi-0.2.0 → modus_operandi-0.2.2}/PKG-INFO +8 -1
  2. {modus_operandi-0.2.0 → modus_operandi-0.2.2}/README.md +7 -0
  3. {modus_operandi-0.2.0 → modus_operandi-0.2.2}/pyproject.toml +1 -1
  4. {modus_operandi-0.2.0 → modus_operandi-0.2.2}/pyproject.toml.orig +1 -1
  5. {modus_operandi-0.2.0 → modus_operandi-0.2.2}/src/modus_operandi/__init__.py +1 -1
  6. {modus_operandi-0.2.0 → modus_operandi-0.2.2}/src/modus_operandi/config.py +77 -6
  7. {modus_operandi-0.2.0 → modus_operandi-0.2.2}/src/modus_operandi/data/pipeline_scripts/config_invocation.py +71 -15
  8. {modus_operandi-0.2.0 → modus_operandi-0.2.2}/src/modus_operandi/data/pipeline_scripts/editor.py +12 -8
  9. {modus_operandi-0.2.0 → modus_operandi-0.2.2}/src/modus_operandi/data/pipeline_scripts/feedback_editor.py +8 -8
  10. {modus_operandi-0.2.0 → modus_operandi-0.2.2}/src/modus_operandi/data/pipeline_scripts/run_pipeline.py +18 -7
  11. {modus_operandi-0.2.0 → modus_operandi-0.2.2}/src/modus_operandi/data/workflows/task-pipeline.yml +3 -3
  12. {modus_operandi-0.2.0 → modus_operandi-0.2.2}/src/modus_operandi/editor.py +12 -8
  13. {modus_operandi-0.2.0 → modus_operandi-0.2.2}/LICENSE +0 -0
  14. {modus_operandi-0.2.0 → modus_operandi-0.2.2}/src/modus_operandi/bootstrap.py +0 -0
  15. {modus_operandi-0.2.0 → modus_operandi-0.2.2}/src/modus_operandi/cli.py +0 -0
  16. {modus_operandi-0.2.0 → modus_operandi-0.2.2}/src/modus_operandi/data/config.example.yml +0 -0
  17. {modus_operandi-0.2.0 → modus_operandi-0.2.2}/src/modus_operandi/data/pipeline_scripts/adr-task-id.sh +0 -0
  18. {modus_operandi-0.2.0 → modus_operandi-0.2.2}/src/modus_operandi/data/pipeline_scripts/adr_utils.py +0 -0
  19. {modus_operandi-0.2.0 → modus_operandi-0.2.2}/src/modus_operandi/data/pipeline_scripts/agent-step.sh +0 -0
  20. {modus_operandi-0.2.0 → modus_operandi-0.2.2}/src/modus_operandi/data/pipeline_scripts/agent_call.py +0 -0
  21. {modus_operandi-0.2.0 → modus_operandi-0.2.2}/src/modus_operandi/data/pipeline_scripts/agent_log_tailer.py +0 -0
  22. {modus_operandi-0.2.0 → modus_operandi-0.2.2}/src/modus_operandi/data/pipeline_scripts/buffered_emitter.py +0 -0
  23. {modus_operandi-0.2.0 → modus_operandi-0.2.2}/src/modus_operandi/data/pipeline_scripts/check_implementation.py +0 -0
  24. {modus_operandi-0.2.0 → modus_operandi-0.2.2}/src/modus_operandi/data/pipeline_scripts/check_plan_deviation.py +0 -0
  25. {modus_operandi-0.2.0 → modus_operandi-0.2.2}/src/modus_operandi/data/pipeline_scripts/check_questions.py +0 -0
  26. {modus_operandi-0.2.0 → modus_operandi-0.2.2}/src/modus_operandi/data/pipeline_scripts/check_review.py +0 -0
  27. {modus_operandi-0.2.0 → modus_operandi-0.2.2}/src/modus_operandi/data/pipeline_scripts/clear-feedback.sh +0 -0
  28. {modus_operandi-0.2.0 → modus_operandi-0.2.2}/src/modus_operandi/data/pipeline_scripts/determine-scope.sh +0 -0
  29. {modus_operandi-0.2.0 → modus_operandi-0.2.2}/src/modus_operandi/data/pipeline_scripts/display.py +0 -0
  30. {modus_operandi-0.2.0 → modus_operandi-0.2.2}/src/modus_operandi/data/pipeline_scripts/engine_output.py +0 -0
  31. {modus_operandi-0.2.0 → modus_operandi-0.2.2}/src/modus_operandi/data/pipeline_scripts/feedback_gate.py +0 -0
  32. {modus_operandi-0.2.0 → modus_operandi-0.2.2}/src/modus_operandi/data/pipeline_scripts/gate_state.py +0 -0
  33. {modus_operandi-0.2.0 → modus_operandi-0.2.2}/src/modus_operandi/data/pipeline_scripts/implement-pass-check.sh +0 -0
  34. {modus_operandi-0.2.0 → modus_operandi-0.2.2}/src/modus_operandi/data/pipeline_scripts/implement-retry.sh +0 -0
  35. {modus_operandi-0.2.0 → modus_operandi-0.2.2}/src/modus_operandi/data/pipeline_scripts/latency_table.py +0 -0
  36. {modus_operandi-0.2.0 → modus_operandi-0.2.2}/src/modus_operandi/data/pipeline_scripts/live_lines.py +0 -0
  37. {modus_operandi-0.2.0 → modus_operandi-0.2.2}/src/modus_operandi/data/pipeline_scripts/live_monitor.py +0 -0
  38. {modus_operandi-0.2.0 → modus_operandi-0.2.2}/src/modus_operandi/data/pipeline_scripts/name-task.sh +0 -0
  39. {modus_operandi-0.2.0 → modus_operandi-0.2.2}/src/modus_operandi/data/pipeline_scripts/notify.py +0 -0
  40. {modus_operandi-0.2.0 → modus_operandi-0.2.2}/src/modus_operandi/data/pipeline_scripts/pass-check.sh +0 -0
  41. {modus_operandi-0.2.0 → modus_operandi-0.2.2}/src/modus_operandi/data/pipeline_scripts/prompt_subst.sh +0 -0
  42. {modus_operandi-0.2.0 → modus_operandi-0.2.2}/src/modus_operandi/data/pipeline_scripts/pty_spawn.py +0 -0
  43. {modus_operandi-0.2.0 → modus_operandi-0.2.2}/src/modus_operandi/data/pipeline_scripts/review-check.sh +0 -0
  44. {modus_operandi-0.2.0 → modus_operandi-0.2.2}/src/modus_operandi/data/pipeline_scripts/review-task-id.sh +0 -0
  45. {modus_operandi-0.2.0 → modus_operandi-0.2.2}/src/modus_operandi/data/pipeline_scripts/run-agent-cursor.sh +0 -0
  46. {modus_operandi-0.2.0 → modus_operandi-0.2.2}/src/modus_operandi/data/pipeline_scripts/run-agent.sh +0 -0
  47. {modus_operandi-0.2.0 → modus_operandi-0.2.2}/src/modus_operandi/data/pipeline_scripts/run_finish.py +0 -0
  48. {modus_operandi-0.2.0 → modus_operandi-0.2.2}/src/modus_operandi/data/pipeline_scripts/run_id_discoverer.py +0 -0
  49. {modus_operandi-0.2.0 → modus_operandi-0.2.2}/src/modus_operandi/data/pipeline_scripts/run_state.py +0 -0
  50. {modus_operandi-0.2.0 → modus_operandi-0.2.2}/src/modus_operandi/data/pipeline_scripts/run_statistics.py +0 -0
  51. {modus_operandi-0.2.0 → modus_operandi-0.2.2}/src/modus_operandi/data/pipeline_scripts/save_adr.py +0 -0
  52. {modus_operandi-0.2.0 → modus_operandi-0.2.2}/src/modus_operandi/data/pipeline_scripts/session_store.sh +0 -0
  53. {modus_operandi-0.2.0 → modus_operandi-0.2.2}/src/modus_operandi/data/pipeline_scripts/show-file.sh +0 -0
  54. {modus_operandi-0.2.0 → modus_operandi-0.2.2}/src/modus_operandi/data/pipeline_scripts/stdout_reader.py +0 -0
  55. {modus_operandi-0.2.0 → modus_operandi-0.2.2}/src/modus_operandi/data/pipeline_scripts/step_result_poller.py +0 -0
  56. {modus_operandi-0.2.0 → modus_operandi-0.2.2}/src/modus_operandi/data/pipeline_scripts/table_format.py +0 -0
  57. {modus_operandi-0.2.0 → modus_operandi-0.2.2}/src/modus_operandi/data/pipeline_scripts/task_utils.py +0 -0
  58. {modus_operandi-0.2.0 → modus_operandi-0.2.2}/src/modus_operandi/data/pipeline_scripts/usage_parser.py +0 -0
  59. {modus_operandi-0.2.0 → modus_operandi-0.2.2}/src/modus_operandi/data/pipeline_scripts/validate_inputs.py +0 -0
  60. {modus_operandi-0.2.0 → modus_operandi-0.2.2}/src/modus_operandi/data/pipeline_scripts/workflow_info.py +0 -0
  61. {modus_operandi-0.2.0 → modus_operandi-0.2.2}/src/modus_operandi/data/pipeline_scripts/wrapper_cli.py +0 -0
  62. {modus_operandi-0.2.0 → modus_operandi-0.2.2}/src/modus_operandi/data/prompts/adr/bug-review.md +0 -0
  63. {modus_operandi-0.2.0 → modus_operandi-0.2.2}/src/modus_operandi/data/prompts/adr/comment-fix.md +0 -0
  64. {modus_operandi-0.2.0 → modus_operandi-0.2.2}/src/modus_operandi/data/prompts/adr/comment-review.md +0 -0
  65. {modus_operandi-0.2.0 → modus_operandi-0.2.2}/src/modus_operandi/data/prompts/adr/executor-questions.md +0 -0
  66. {modus_operandi-0.2.0 → modus_operandi-0.2.2}/src/modus_operandi/data/prompts/adr/implement-continue.md +0 -0
  67. {modus_operandi-0.2.0 → modus_operandi-0.2.2}/src/modus_operandi/data/prompts/adr/implement-retry.md +0 -0
  68. {modus_operandi-0.2.0 → modus_operandi-0.2.2}/src/modus_operandi/data/prompts/adr/implement.md +0 -0
  69. {modus_operandi-0.2.0 → modus_operandi-0.2.2}/src/modus_operandi/data/prompts/adr/planner-agreement.md +0 -0
  70. {modus_operandi-0.2.0 → modus_operandi-0.2.2}/src/modus_operandi/data/prompts/adr/planner-answers.md +0 -0
  71. {modus_operandi-0.2.0 → modus_operandi-0.2.2}/src/modus_operandi/data/prompts/adr/review.md +0 -0
  72. {modus_operandi-0.2.0 → modus_operandi-0.2.2}/src/modus_operandi/data/prompts/adr/srp-review.md +0 -0
  73. {modus_operandi-0.2.0 → modus_operandi-0.2.2}/src/modus_operandi/data/prompts/bug-fix.md +0 -0
  74. {modus_operandi-0.2.0 → modus_operandi-0.2.2}/src/modus_operandi/data/prompts/fix-all.md +0 -0
  75. {modus_operandi-0.2.0 → modus_operandi-0.2.2}/src/modus_operandi/data/prompts/fix.md +0 -0
  76. {modus_operandi-0.2.0 → modus_operandi-0.2.2}/src/modus_operandi/data/prompts/review/bug-rereview.md +0 -0
  77. {modus_operandi-0.2.0 → modus_operandi-0.2.2}/src/modus_operandi/data/prompts/review/bug-review.md +0 -0
  78. {modus_operandi-0.2.0 → modus_operandi-0.2.2}/src/modus_operandi/data/prompts/review/comment-fix.md +0 -0
  79. {modus_operandi-0.2.0 → modus_operandi-0.2.2}/src/modus_operandi/data/prompts/review/comment-rereview.md +0 -0
  80. {modus_operandi-0.2.0 → modus_operandi-0.2.2}/src/modus_operandi/data/prompts/review/comment-review.md +0 -0
  81. {modus_operandi-0.2.0 → modus_operandi-0.2.2}/src/modus_operandi/data/prompts/review/review-rereview.md +0 -0
  82. {modus_operandi-0.2.0 → modus_operandi-0.2.2}/src/modus_operandi/data/prompts/review/review.md +0 -0
  83. {modus_operandi-0.2.0 → modus_operandi-0.2.2}/src/modus_operandi/data/prompts/review/srp-rereview.md +0 -0
  84. {modus_operandi-0.2.0 → modus_operandi-0.2.2}/src/modus_operandi/data/prompts/review/srp-review.md +0 -0
  85. {modus_operandi-0.2.0 → modus_operandi-0.2.2}/src/modus_operandi/data/prompts/srp-fix.md +0 -0
  86. {modus_operandi-0.2.0 → modus_operandi-0.2.2}/src/modus_operandi/data/prompts/task/research.md +0 -0
  87. {modus_operandi-0.2.0 → modus_operandi-0.2.2}/src/modus_operandi/data/prompts/task/study-revise.md +0 -0
  88. {modus_operandi-0.2.0 → modus_operandi-0.2.2}/src/modus_operandi/data/prompts/task/study.md +0 -0
  89. {modus_operandi-0.2.0 → modus_operandi-0.2.2}/src/modus_operandi/data/prompts/task/write-adr.md +0 -0
  90. {modus_operandi-0.2.0 → modus_operandi-0.2.2}/src/modus_operandi/data/prompts/task/write-plan.md +0 -0
  91. {modus_operandi-0.2.0 → modus_operandi-0.2.2}/src/modus_operandi/data/roles/executor.md +0 -0
  92. {modus_operandi-0.2.0 → modus_operandi-0.2.2}/src/modus_operandi/data/roles/planner.md +0 -0
  93. {modus_operandi-0.2.0 → modus_operandi-0.2.2}/src/modus_operandi/data/roles/reviewer-bugs.md +0 -0
  94. {modus_operandi-0.2.0 → modus_operandi-0.2.2}/src/modus_operandi/data/roles/reviewer-comment.md +0 -0
  95. {modus_operandi-0.2.0 → modus_operandi-0.2.2}/src/modus_operandi/data/roles/reviewer-review.md +0 -0
  96. {modus_operandi-0.2.0 → modus_operandi-0.2.2}/src/modus_operandi/data/roles/reviewer-srp.md +0 -0
  97. {modus_operandi-0.2.0 → modus_operandi-0.2.2}/src/modus_operandi/data/victory.wav +0 -0
  98. {modus_operandi-0.2.0 → modus_operandi-0.2.2}/src/modus_operandi/data/workflows/review-pipeline.yml +0 -0
  99. {modus_operandi-0.2.0 → modus_operandi-0.2.2}/src/modus_operandi/edit_command.py +0 -0
  100. {modus_operandi-0.2.0 → modus_operandi-0.2.2}/src/modus_operandi/exceptions/__init__.py +0 -0
  101. {modus_operandi-0.2.0 → modus_operandi-0.2.2}/src/modus_operandi/exceptions/edit_requested.py +0 -0
  102. {modus_operandi-0.2.0 → modus_operandi-0.2.2}/src/modus_operandi/exceptions/help_requested.py +0 -0
  103. {modus_operandi-0.2.0 → modus_operandi-0.2.2}/src/modus_operandi/exceptions/invalid_invocation.py +0 -0
  104. {modus_operandi-0.2.0 → modus_operandi-0.2.2}/src/modus_operandi/exceptions/uninstall_requested.py +0 -0
  105. {modus_operandi-0.2.0 → modus_operandi-0.2.2}/src/modus_operandi/installer.py +0 -0
  106. {modus_operandi-0.2.0 → modus_operandi-0.2.2}/src/modus_operandi/installer_cli.py +0 -0
  107. {modus_operandi-0.2.0 → modus_operandi-0.2.2}/src/modus_operandi/paths.py +0 -0
  108. {modus_operandi-0.2.0 → modus_operandi-0.2.2}/src/modus_operandi/proc.py +0 -0
  109. {modus_operandi-0.2.0 → modus_operandi-0.2.2}/src/modus_operandi/prompt.py +0 -0
  110. {modus_operandi-0.2.0 → modus_operandi-0.2.2}/src/modus_operandi/render.py +0 -0
  111. {modus_operandi-0.2.0 → modus_operandi-0.2.2}/src/modus_operandi/tool_discovery.py +0 -0
  112. {modus_operandi-0.2.0 → modus_operandi-0.2.2}/src/modus_operandi/uninstall.py +0 -0
  113. {modus_operandi-0.2.0 → modus_operandi-0.2.2}/src/modus_operandi/verify.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: modus-operandi
3
- Version: 0.2.0
3
+ Version: 0.2.2
4
4
  Summary: Spec-driven planner -> executor pipelines for opencode and cursor
5
5
  Author: Denis Riazantsev
6
6
  Author-email: Denis Riazantsev <metacodeine@gmail.com>
@@ -24,6 +24,9 @@ Description-Content-Type: text/markdown
24
24
 
25
25
  # Modus Operandi 🤌
26
26
 
27
+ [![PyPI version](https://img.shields.io/pypi/v/modus-operandi.svg)](https://pypi.org/project/modus-operandi/)
28
+ [![Python versions](https://img.shields.io/pypi/pyversions/modus-operandi.svg)](https://pypi.org/project/modus-operandi/)
29
+
27
30
  Automate spec-driven "planner -> executor" workflows in LLM clients (opencode
28
31
  and cursor): task planning, task implementing and code review.
29
32
 
@@ -120,6 +123,10 @@ By default, opencode is used. If you need the cursor backend, use the
120
123
  modus-operandi --backend cursor task "<task-description>"
121
124
  ```
122
125
 
126
+ Cursor runs on its default `composer-2` model for planning, review and
127
+ implementation when no `cursor:` section is configured yet; set your own
128
+ model slugs with `modus-operandi edit`.
129
+
123
130
  ### Config
124
131
 
125
132
  ```shell
@@ -1,5 +1,8 @@
1
1
  # Modus Operandi 🤌
2
2
 
3
+ [![PyPI version](https://img.shields.io/pypi/v/modus-operandi.svg)](https://pypi.org/project/modus-operandi/)
4
+ [![Python versions](https://img.shields.io/pypi/pyversions/modus-operandi.svg)](https://pypi.org/project/modus-operandi/)
5
+
3
6
  Automate spec-driven "planner -> executor" workflows in LLM clients (opencode
4
7
  and cursor): task planning, task implementing and code review.
5
8
 
@@ -96,6 +99,10 @@ By default, opencode is used. If you need the cursor backend, use the
96
99
  modus-operandi --backend cursor task "<task-description>"
97
100
  ```
98
101
 
102
+ Cursor runs on its default `composer-2` model for planning, review and
103
+ implementation when no `cursor:` section is configured yet; set your own
104
+ model slugs with `modus-operandi edit`.
105
+
99
106
  ### Config
100
107
 
101
108
  ```shell
@@ -4,7 +4,7 @@ build-backend = "uv_build"
4
4
 
5
5
  [project]
6
6
  name = "modus-operandi"
7
- version = "0.2.0"
7
+ version = "0.2.2"
8
8
  description = "Spec-driven planner -> executor pipelines for opencode and cursor"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.12"
@@ -4,7 +4,7 @@ build-backend = "uv_build"
4
4
 
5
5
  [project]
6
6
  name = "modus-operandi"
7
- version = "0.2.0"
7
+ version = "0.2.2"
8
8
  description = "Spec-driven planner -> executor pipelines for opencode and cursor"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.12"
@@ -17,7 +17,7 @@ from .paths import Paths
17
17
  # defined in this module.)
18
18
  __all__ = ["InstallError", "Paths", "__version__"]
19
19
 
20
- __version__ = "0.2.0"
20
+ __version__ = "0.2.2"
21
21
 
22
22
 
23
23
  class InstallError(Exception):
@@ -23,6 +23,24 @@ DEFAULT_ADR_DIR = "architecture"
23
23
  DEFAULT_BACKEND = "opencode"
24
24
  BACKENDS = ("opencode", "cursor")
25
25
 
26
+ # The shipped per-backend role model defaults, mirroring config.example.yml
27
+ # (opencode: the free OpenCode Zen big-pickle; cursor: Cursor's default
28
+ # composer-2). apply_defaults fills the ACTIVE backend's missing role models
29
+ # from these, so switching backends with `modus-operandi edit` works out of
30
+ # the box even on a config created before the backend's section existed
31
+ # (e.g. a legacy pre-cursor config); an existing value (even a placeholder)
32
+ # is never overwritten, so validation still catches placeholders.
33
+ DEFAULT_MODELS: dict[str, dict[str, dict[str, str]]] = {
34
+ "opencode": {
35
+ "planner": {"provider": "opencode", "model": "big-pickle", "reasoning": DEFAULT_REASONING},
36
+ "executor": {"provider": "opencode", "model": "big-pickle", "reasoning": DEFAULT_REASONING},
37
+ },
38
+ "cursor": {
39
+ "planner": {"model": "composer-2"},
40
+ "executor": {"model": "composer-2"},
41
+ },
42
+ }
43
+
26
44
  DEFAULT_CONFIG: dict[str, Any] = {
27
45
  "backend": DEFAULT_BACKEND,
28
46
  "workflow": {
@@ -51,17 +69,27 @@ def load_config(path: str | Path) -> dict[str, Any]:
51
69
  # without special-casing yaml types. A root that is valid YAML but not a
52
70
  # mapping (a bare scalar or a list) is the same class of user error, and
53
71
  # dict(raw) would otherwise raise an unhandled TypeError instead.
72
+ # The file is read as BYTES so PyYAML detects the encodings a desktop
73
+ # editor can save (UTF-8, UTF-16/UTF-32 with BOM); a BOM-less UTF-16 save
74
+ # falls back to an explicit decode — otherwise `modus-operandi edit`
75
+ # rejected (and rolled back) a config saved as UTF-16 by a macOS editor.
54
76
  # PyYAML is a declared wheel dependency (pyproject.toml `dependencies`),
55
77
  # so the import is unconditional.
56
- with open(path, encoding="utf-8") as fh:
57
- text = fh.read()
58
78
  try:
59
- raw = yaml.safe_load(text) or {}
60
- except yaml.YAMLError as exc:
79
+ raw = Path(path).read_bytes()
80
+ except OSError as exc:
81
+ raise InstallError(f"cannot read config.yml: {exc}") from exc
82
+ try:
83
+ try:
84
+ parsed = yaml.safe_load(raw)
85
+ except yaml.YAMLError:
86
+ parsed = yaml.safe_load(raw.decode("utf-16"))
87
+ except (UnicodeDecodeError, yaml.YAMLError) as exc:
61
88
  raise InstallError(f"invalid config.yml: {exc}") from exc
62
- if not isinstance(raw, dict):
89
+ raw_cfg = parsed or {}
90
+ if not isinstance(raw_cfg, dict):
63
91
  raise InstallError("invalid config.yml: top-level must be a mapping")
64
- return dict(raw)
92
+ return dict(raw_cfg)
65
93
 
66
94
 
67
95
  def opencode_models_complete(cfg: dict[str, Any]) -> bool:
@@ -84,6 +112,45 @@ def opencode_models_complete(cfg: dict[str, Any]) -> bool:
84
112
  )
85
113
 
86
114
 
115
+ def _fill_default_models(cfg: dict[str, Any], backend: str) -> None:
116
+ """Fill the ACTIVE backend's missing role models with the shipped defaults.
117
+
118
+ Only the active backend is completed (DEFAULT_MODELS): switching backends
119
+ with `modus-operandi edit` must not fail on a config that predates the
120
+ target backend's section (e.g. a legacy config without a cursor section),
121
+ while the inactive section stays untouched — an empty opencode section
122
+ keeps rendering no agent files. Existing values are never overwritten (a
123
+ placeholder value stays and fails validation), and a structurally invalid
124
+ section is left alone for the per-section validation below to reject.
125
+ """
126
+ defaults = DEFAULT_MODELS.get(backend)
127
+ if defaults is None:
128
+ return
129
+ section = cfg.get(backend)
130
+ if section is None:
131
+ section = {}
132
+ if not isinstance(section, dict):
133
+ return
134
+ models = section.get("models")
135
+ if models is None:
136
+ models = {}
137
+ if not isinstance(models, dict):
138
+ return
139
+ for role in ("planner", "executor"):
140
+ model = models.get(role)
141
+ if model is None:
142
+ model = {}
143
+ if not isinstance(model, dict):
144
+ continue
145
+ merged = dict(defaults[role])
146
+ for key, value in model.items():
147
+ if value is not None:
148
+ merged[key] = value
149
+ models[role] = merged
150
+ section["models"] = models
151
+ cfg[backend] = section
152
+
153
+
87
154
  def apply_defaults(raw: Any) -> dict[str, Any]:
88
155
  # Same guard as load_config: raw may be None (treated as empty config) but
89
156
  # any other non-mapping top level is a user error, not a TypeError.
@@ -98,6 +165,10 @@ def apply_defaults(raw: Any) -> dict[str, Any]:
98
165
  cfg["opencode"] = {"models": cfg["models"]}
99
166
  del cfg["models"]
100
167
  cfg.setdefault("backend", DEFAULT_BACKEND)
168
+ # Complete the active backend's models (see _fill_default_models): the
169
+ # active section is always usable out of the box, the inactive one is
170
+ # validated structurally below, nothing more.
171
+ _fill_default_models(cfg, cfg["backend"])
101
172
  workflow = cfg.get("workflow") or {}
102
173
  if not isinstance(workflow, dict):
103
174
  raise InstallError("invalid config.yml: workflow must be a mapping")
@@ -26,6 +26,24 @@ CONFIG_PATH = Path(
26
26
  # fallback for an invalid config value).
27
27
  BACKENDS = ("opencode", "cursor")
28
28
 
29
+ # The shipped per-backend default role model slugs, mirroring
30
+ # modus_operandi/config.py (DEFAULT_MODELS). The wrapper reads the raw YAML
31
+ # without apply_defaults, so a model missing from the ACTIVE backend's
32
+ # section (a legacy config without a cursor section, a --backend override on
33
+ # a config that does not configure the backend) falls back to the shipped
34
+ # default instead of exporting an empty MO_*_MODEL that fails deep in
35
+ # run-agent.sh. Keep the two in sync.
36
+ _DEFAULT_MODELS: dict[str, dict[str, dict[str, str]]] = {
37
+ "opencode": {
38
+ "planner": {"model": "big-pickle"},
39
+ "executor": {"model": "big-pickle"},
40
+ },
41
+ "cursor": {
42
+ "planner": {"model": "composer-2"},
43
+ "executor": {"model": "composer-2"},
44
+ },
45
+ }
46
+
29
47
 
30
48
  def normalize_config(cfg: dict[str, Any]) -> dict[str, Any]:
31
49
  """Fold the legacy top-level `models:` into `opencode.models`.
@@ -90,10 +108,14 @@ def sound_settings(cfg: dict[str, Any], config_path: Path) -> tuple[bool, str]:
90
108
 
91
109
 
92
110
  def role_model(cfg: dict[str, Any], backend: str, role: str) -> str:
93
- """Model slug of a role under the active backend ("" when unset).
111
+ """Model slug of a role under the active backend.
94
112
 
95
113
  The opencode agent files carry the opencode model; the exported value is
96
- only consumed by the cursor branch of run-agent.sh/name-task.sh.
114
+ only consumed by the cursor branch of run-agent.sh/name-task.sh. A model
115
+ missing from the active backend's section falls back to the shipped
116
+ default (_DEFAULT_MODELS, mirroring the installer's apply_defaults), so a
117
+ legacy config or a `--backend` override on a config that does not
118
+ configure the backend still runs instead of exporting an empty value.
97
119
  """
98
120
  section = cfg.get("cursor") if backend == "cursor" else cfg.get("opencode")
99
121
  if not isinstance(section, dict):
@@ -107,30 +129,64 @@ def role_model(cfg: dict[str, Any], backend: str, role: str) -> str:
107
129
  model = models.get(role)
108
130
  model = model if isinstance(model, dict) else {}
109
131
  value = model.get("model")
110
- return value if isinstance(value, str) else ""
132
+ if isinstance(value, str) and value:
133
+ return value
134
+ default = _DEFAULT_MODELS.get(backend, {}).get(role, {}).get("model")
135
+ return default if isinstance(default, str) else ""
111
136
 
112
137
 
113
138
  def load_config(config_path: Path | None = None) -> dict[str, Any]:
114
- """Read the installed config.yml into a dict, or {} on any failure.
139
+ """Read the installed config.yml into a dict, or {} when it is missing.
115
140
 
116
141
  config_path defaults to the module's CONFIG_PATH; the run-pipeline entry
117
142
  passes its own CONFIG_PATH explicitly so a runtime override (e.g. tests
118
- pointing the wrapper at another file) is honored. A missing or unreadable
119
- file degrades to the documented defaults, so a hand-invoked wrapper (or a
120
- config deleted after install) still runs.
143
+ pointing the wrapper at another file) is honored. A MISSING file degrades
144
+ to the documented defaults, so a hand-invoked wrapper (or a config
145
+ deleted after install) still runs. An EXISTING file that cannot be read
146
+ or parsed raises ValueError with a user-facing explanation: silently
147
+ running the pipeline with the opencode defaults on a broken config is
148
+ what hid backend misconfiguration (a config that fails to load made
149
+ `backend: cursor` invisible, so the run used opencode and exported empty
150
+ role models), so a broken config now aborts before the run starts —
151
+ mirroring sound_settings. The file is read as BYTES: PyYAML detects the
152
+ UTF-8 and UTF-16/UTF-32 (with BOM) encodings a desktop text editor can
153
+ save, so a macOS TextEdit UTF-16 save still parses instead of failing.
121
154
  """
122
155
  path = config_path if config_path is not None else CONFIG_PATH
123
156
  try:
124
- text = path.read_text(encoding="utf-8")
125
- except OSError:
157
+ raw = path.read_bytes()
158
+ except FileNotFoundError:
126
159
  return {}
127
- try:
128
- import yaml # pyproject.toml declares pyyaml as a package dependency
129
-
130
- data = yaml.safe_load(text) or {}
131
- except Exception:
160
+ except OSError as exc:
161
+ raise ValueError(f"cannot read config {path}: {exc}") from exc
162
+ data = _safe_yaml_load(raw)
163
+ if data is None:
132
164
  return {}
133
- return data if isinstance(data, dict) else {}
165
+ if not isinstance(data, dict):
166
+ raise ValueError(
167
+ f"invalid config.yml at {path}: top-level must be a mapping, got {type(data).__name__}"
168
+ )
169
+ return data
170
+
171
+
172
+ def _safe_yaml_load(raw: bytes) -> Any:
173
+ """Parse config bytes into a value, tolerating editor-saved encodings.
174
+
175
+ PyYAML given BYTES detects UTF-8 (plain and with BOM) and UTF-16/UTF-32
176
+ with BOM natively; a BOM-less UTF-16 file (an editor that saves
177
+ "UTF-16 LE") falls back to an explicit decode. Raises ValueError with
178
+ the parse error when neither works.
179
+ """
180
+ import yaml # pyproject.toml declares pyyaml as a package dependency
181
+
182
+ try:
183
+ return yaml.safe_load(raw)
184
+ except yaml.YAMLError:
185
+ pass
186
+ try:
187
+ return yaml.safe_load(raw.decode("utf-16"))
188
+ except (UnicodeDecodeError, yaml.YAMLError) as exc:
189
+ raise ValueError(f"invalid config.yml: cannot parse the file: {exc}") from exc
134
190
 
135
191
 
136
192
  def build_specify_invocation(
@@ -58,21 +58,25 @@ def resolve_editor() -> list[str] | None:
58
58
  def resolve_feedback_editor() -> tuple[str, list[str]] | None:
59
59
  """Resolve the platform editor for the feedback gates (ADR-0011).
60
60
 
61
- Returns (mode, cmd): mode is "waited" — run the editor blocking and
62
- answer the gate with `continue` on close — or "detached" — launch it in
63
- a separate window and leave the gate interactive (the user presses
64
- `continue`). None means no editor is available at all.
61
+ Returns (mode, cmd): mode is "detached" — launch the editor in a
62
+ separate window and leave the gate interactive (the user presses
63
+ `continue` in the terminal) — or "waited" — run the editor blocking in
64
+ the terminal and answer the gate with `continue` on close. None means
65
+ no editor is available at all.
65
66
 
66
- macOS: TextEdit via `open -a TextEdit -W` (always installed; the -W flag
67
- blocks until the app closes), waited. Linux: GNOME Text Editor in a
68
- separate window — Flatpak first (checked via `flatpak info
67
+ macOS: TextEdit via `open -a TextEdit` (always installed), detached —
68
+ the -W flag is deliberately NOT used: it waits for the whole app to
69
+ quit (closing the document window is not enough, and an already-running
70
+ TextEdit makes `open -W` block until THAT instance exits), which hung
71
+ the run at the feedback gate with no menu on screen. Linux: GNOME Text
72
+ Editor in a separate window — Flatpak first (checked via `flatpak info
69
73
  org.gnome.TextEditor`), then the RPM binary, then the generic desktop
70
74
  opener (gio, then xdg-open) — all detached, since these launchers return
71
75
  immediately. Fallback: the terminal chain ($VISUAL, $EDITOR, nano, vi),
72
76
  waited.
73
77
  """
74
78
  if sys.platform == "darwin":
75
- return "waited", ["open", "-a", "TextEdit", "-W"]
79
+ return "detached", ["open", "-a", "TextEdit"]
76
80
  if sys.platform.startswith("linux"):
77
81
  if shutil.which("flatpak"):
78
82
  try:
@@ -68,14 +68,14 @@ def open_feedback_editor(
68
68
  in the platform editor and, on editor close,
69
69
  answers the gate with `continue` so the workflow continues (the revise
70
70
  step reads feedback.md).
71
- The editor resolution (editor.py) returns a mode: "waited" (macOS
72
- TextEdit or the terminal chain) runs the editor blocking and the wrapper
73
- answers the gate; "detached" (Linux GUI in a separate window) launches
74
- the editor and leaves the gate interactive — the user closes the window
75
- and presses `continue`. Returns True when the wrapper answered the gate;
76
- False on fallback — no TTY, no editor, or a detached launch — where the
77
- file is still created, forwarding is left running and the gate stays
78
- interactive for manual input.
71
+ The editor resolution (editor.py) returns a mode: "detached" (macOS
72
+ TextEdit, Linux GUI in a separate window) launches the editor and
73
+ leaves the gate interactive — the user closes the window and presses
74
+ `continue`; "waited" (the terminal chain) runs the editor blocking and
75
+ the wrapper answers the gate. Returns True when the wrapper answered
76
+ the gate; False on fallback — no TTY, no editor, or a detached launch —
77
+ where the file is still created, forwarding is left running and the
78
+ gate stays interactive for manual input.
79
79
  """
80
80
  task_dir = Path.cwd() / state_dir / "tasks" / "current"
81
81
  source_doc: Path | None = None
@@ -18,11 +18,15 @@ to specify (keeping sys.stdin.isatty() True inside it) and terminal input is
18
18
  forwarded into the master end, so interactive gates keep working while the
19
19
  wrapper can answer the feedback gate itself. When that gate opens
20
20
  the wrapper creates <state_dir>/tasks/current/feedback.md (never overwriting
21
- an existing file), opens it in the terminal editor ($VISUAL, then $EDITOR,
22
- then nano, then vi), and on editor close answers the gate with `continue` so
23
- the workflow resumes (study-revise reads feedback.md). If no editor can run
24
- (non-TTY or none found), the file is still created and the gate stays
25
- interactive for manual input. On a non-TTY run no pty is used and specify
21
+ an existing file) and opens it in the platform editor — macOS TextEdit and
22
+ the Linux GUI open in a separate window and the run NEVER waits for the
23
+ editor: the gate stays interactive and the user answers it in the terminal
24
+ (`continue`/`abort`) after closing the editor; without a GUI the editor runs
25
+ in the terminal ($VISUAL, then $EDITOR, then nano, then vi) and the wrapper
26
+ answers the gate with `continue` on editor close so the workflow resumes
27
+ (study-revise reads feedback.md). If no editor can run (non-TTY or none
28
+ found), the file is still created and the gate stays interactive for manual
29
+ input. On a non-TTY run no pty is used and specify
26
30
  keeps the inherited stdin, so gates keep going PAUSED exactly as before.
27
31
 
28
32
  When the run finishes (success, failure or abort alike) the wrapper prints a
@@ -218,8 +222,15 @@ def main() -> int:
218
222
  # Map the installed config + argv to the specify invocation; main() only
219
223
  # orchestrates the live run from the result. CONFIG_PATH is passed
220
224
  # explicitly so a runtime override of this module's CONFIG_PATH (e.g. by
221
- # tests) is honored by the config read.
222
- cfg = load_config(CONFIG_PATH)
225
+ # tests) is honored by the config read. An existing but unreadable or
226
+ # invalid config aborts with an explanation instead of silently running
227
+ # with the opencode defaults (a broken config used to make `backend:
228
+ # cursor` invisible and export empty role models).
229
+ try:
230
+ cfg = load_config(CONFIG_PATH)
231
+ except ValueError as exc:
232
+ print(f"error: {exc}", file=sys.stderr)
233
+ return 1
223
234
  # The sound section is validated before the run starts (ADR-0018): an
224
235
  # invalid sound setting aborts with an explanation, not a silent degrade.
225
236
  try:
@@ -121,9 +121,9 @@ steps:
121
121
  message: >-
122
122
  Feedback on the motivation study is collected in
123
123
  {{ inputs.state_dir }}/tasks/current/feedback.md — the wrapper opens it
124
- in your editor: on macOS the run continues automatically when the
125
- editor closes; on Linux close the editor window and choose
126
- continue. If no editor opened, create the file manually
124
+ in your editor; the run never waits for the editor: edit and
125
+ save the file, close the editor window, then choose continue
126
+ in the terminal. If no editor opened, create the file manually
127
127
  and write your feedback, then choose continue. The planner
128
128
  will update study.md accordingly.
129
129
  options: [continue, abort]
@@ -58,21 +58,25 @@ def resolve_editor() -> list[str] | None:
58
58
  def resolve_feedback_editor() -> tuple[str, list[str]] | None:
59
59
  """Resolve the platform editor for the feedback gates (ADR-0011).
60
60
 
61
- Returns (mode, cmd): mode is "waited" — run the editor blocking and
62
- answer the gate with `continue` on close — or "detached" — launch it in
63
- a separate window and leave the gate interactive (the user presses
64
- `continue`). None means no editor is available at all.
61
+ Returns (mode, cmd): mode is "detached" — launch the editor in a
62
+ separate window and leave the gate interactive (the user presses
63
+ `continue` in the terminal) — or "waited" — run the editor blocking in
64
+ the terminal and answer the gate with `continue` on close. None means
65
+ no editor is available at all.
65
66
 
66
- macOS: TextEdit via `open -a TextEdit -W` (always installed; the -W flag
67
- blocks until the app closes), waited. Linux: GNOME Text Editor in a
68
- separate window — Flatpak first (checked via `flatpak info
67
+ macOS: TextEdit via `open -a TextEdit` (always installed), detached —
68
+ the -W flag is deliberately NOT used: it waits for the whole app to
69
+ quit (closing the document window is not enough, and an already-running
70
+ TextEdit makes `open -W` block until THAT instance exits), which hung
71
+ the run at the feedback gate with no menu on screen. Linux: GNOME Text
72
+ Editor in a separate window — Flatpak first (checked via `flatpak info
69
73
  org.gnome.TextEditor`), then the RPM binary, then the generic desktop
70
74
  opener (gio, then xdg-open) — all detached, since these launchers return
71
75
  immediately. Fallback: the terminal chain ($VISUAL, $EDITOR, nano, vi),
72
76
  waited.
73
77
  """
74
78
  if sys.platform == "darwin":
75
- return "waited", ["open", "-a", "TextEdit", "-W"]
79
+ return "detached", ["open", "-a", "TextEdit"]
76
80
  if sys.platform.startswith("linux"):
77
81
  if shutil.which("flatpak"):
78
82
  try:
File without changes