modus-operandi 0.1.0__py3-none-any.whl

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 (111) hide show
  1. modus_operandi/__init__.py +24 -0
  2. modus_operandi/bootstrap.py +52 -0
  3. modus_operandi/cli.py +313 -0
  4. modus_operandi/config.py +199 -0
  5. modus_operandi/data/config.example.yml +37 -0
  6. modus_operandi/data/pipeline_scripts/adr-task-id.sh +32 -0
  7. modus_operandi/data/pipeline_scripts/adr_utils.py +74 -0
  8. modus_operandi/data/pipeline_scripts/agent-step.sh +38 -0
  9. modus_operandi/data/pipeline_scripts/agent_call.py +34 -0
  10. modus_operandi/data/pipeline_scripts/agent_log_tailer.py +121 -0
  11. modus_operandi/data/pipeline_scripts/buffered_emitter.py +156 -0
  12. modus_operandi/data/pipeline_scripts/check_implementation.py +109 -0
  13. modus_operandi/data/pipeline_scripts/check_plan_deviation.py +43 -0
  14. modus_operandi/data/pipeline_scripts/check_questions.py +55 -0
  15. modus_operandi/data/pipeline_scripts/check_review.py +149 -0
  16. modus_operandi/data/pipeline_scripts/clear-feedback.sh +12 -0
  17. modus_operandi/data/pipeline_scripts/config_invocation.py +197 -0
  18. modus_operandi/data/pipeline_scripts/determine-scope.sh +57 -0
  19. modus_operandi/data/pipeline_scripts/display.py +89 -0
  20. modus_operandi/data/pipeline_scripts/editor.py +97 -0
  21. modus_operandi/data/pipeline_scripts/engine_output.py +95 -0
  22. modus_operandi/data/pipeline_scripts/feedback_editor.py +136 -0
  23. modus_operandi/data/pipeline_scripts/feedback_gate.py +86 -0
  24. modus_operandi/data/pipeline_scripts/gate_state.py +89 -0
  25. modus_operandi/data/pipeline_scripts/implement-pass-check.sh +23 -0
  26. modus_operandi/data/pipeline_scripts/implement-retry.sh +25 -0
  27. modus_operandi/data/pipeline_scripts/latency_table.py +223 -0
  28. modus_operandi/data/pipeline_scripts/live_lines.py +339 -0
  29. modus_operandi/data/pipeline_scripts/live_monitor.py +201 -0
  30. modus_operandi/data/pipeline_scripts/name-task.sh +90 -0
  31. modus_operandi/data/pipeline_scripts/notify.py +55 -0
  32. modus_operandi/data/pipeline_scripts/pass-check.sh +27 -0
  33. modus_operandi/data/pipeline_scripts/prompt_subst.sh +20 -0
  34. modus_operandi/data/pipeline_scripts/pty_spawn.py +155 -0
  35. modus_operandi/data/pipeline_scripts/review-check.sh +83 -0
  36. modus_operandi/data/pipeline_scripts/review-task-id.sh +22 -0
  37. modus_operandi/data/pipeline_scripts/run-agent-cursor.sh +208 -0
  38. modus_operandi/data/pipeline_scripts/run-agent.sh +268 -0
  39. modus_operandi/data/pipeline_scripts/run_finish.py +102 -0
  40. modus_operandi/data/pipeline_scripts/run_id_discoverer.py +30 -0
  41. modus_operandi/data/pipeline_scripts/run_pipeline.py +283 -0
  42. modus_operandi/data/pipeline_scripts/run_state.py +33 -0
  43. modus_operandi/data/pipeline_scripts/run_statistics.py +274 -0
  44. modus_operandi/data/pipeline_scripts/save_adr.py +130 -0
  45. modus_operandi/data/pipeline_scripts/session_store.sh +137 -0
  46. modus_operandi/data/pipeline_scripts/show-file.sh +38 -0
  47. modus_operandi/data/pipeline_scripts/stdout_reader.py +117 -0
  48. modus_operandi/data/pipeline_scripts/step_result_poller.py +62 -0
  49. modus_operandi/data/pipeline_scripts/table_format.py +103 -0
  50. modus_operandi/data/pipeline_scripts/task_utils.py +25 -0
  51. modus_operandi/data/pipeline_scripts/usage_parser.py +142 -0
  52. modus_operandi/data/pipeline_scripts/validate_inputs.py +114 -0
  53. modus_operandi/data/pipeline_scripts/warm-planner.sh +47 -0
  54. modus_operandi/data/pipeline_scripts/workflow_info.py +89 -0
  55. modus_operandi/data/pipeline_scripts/wrapper_cli.py +57 -0
  56. modus_operandi/data/prompts/adr/bug-review.md +3 -0
  57. modus_operandi/data/prompts/adr/comment-fix.md +1 -0
  58. modus_operandi/data/prompts/adr/comment-review.md +3 -0
  59. modus_operandi/data/prompts/adr/executor-questions.md +1 -0
  60. modus_operandi/data/prompts/adr/implement-continue.md +1 -0
  61. modus_operandi/data/prompts/adr/implement-retry.md +1 -0
  62. modus_operandi/data/prompts/adr/implement.md +1 -0
  63. modus_operandi/data/prompts/adr/planner-agreement.md +1 -0
  64. modus_operandi/data/prompts/adr/planner-answers.md +1 -0
  65. modus_operandi/data/prompts/adr/review.md +3 -0
  66. modus_operandi/data/prompts/adr/srp-review.md +40 -0
  67. modus_operandi/data/prompts/bug-fix.md +1 -0
  68. modus_operandi/data/prompts/fix-all.md +7 -0
  69. modus_operandi/data/prompts/fix.md +1 -0
  70. modus_operandi/data/prompts/review/bug-rereview.md +1 -0
  71. modus_operandi/data/prompts/review/bug-review.md +1 -0
  72. modus_operandi/data/prompts/review/comment-fix.md +1 -0
  73. modus_operandi/data/prompts/review/comment-rereview.md +1 -0
  74. modus_operandi/data/prompts/review/comment-review.md +1 -0
  75. modus_operandi/data/prompts/review/review-rereview.md +1 -0
  76. modus_operandi/data/prompts/review/review.md +1 -0
  77. modus_operandi/data/prompts/review/srp-rereview.md +25 -0
  78. modus_operandi/data/prompts/review/srp-review.md +42 -0
  79. modus_operandi/data/prompts/review/warmup.md +12 -0
  80. modus_operandi/data/prompts/srp-fix.md +1 -0
  81. modus_operandi/data/prompts/task/research.md +1 -0
  82. modus_operandi/data/prompts/task/study-revise.md +1 -0
  83. modus_operandi/data/prompts/task/study.md +1 -0
  84. modus_operandi/data/prompts/task/write-adr.md +99 -0
  85. modus_operandi/data/prompts/task/write-plan.md +1 -0
  86. modus_operandi/data/roles/executor.md +14 -0
  87. modus_operandi/data/roles/planner.md +13 -0
  88. modus_operandi/data/victory.wav +0 -0
  89. modus_operandi/data/workflows/review-pipeline.yml +107 -0
  90. modus_operandi/data/workflows/task-pipeline.yml +341 -0
  91. modus_operandi/edit_command.py +113 -0
  92. modus_operandi/editor.py +97 -0
  93. modus_operandi/exceptions/__init__.py +17 -0
  94. modus_operandi/exceptions/edit_requested.py +6 -0
  95. modus_operandi/exceptions/help_requested.py +5 -0
  96. modus_operandi/exceptions/invalid_invocation.py +6 -0
  97. modus_operandi/exceptions/uninstall_requested.py +12 -0
  98. modus_operandi/installer.py +74 -0
  99. modus_operandi/installer_cli.py +165 -0
  100. modus_operandi/paths.py +117 -0
  101. modus_operandi/proc.py +25 -0
  102. modus_operandi/prompt.py +12 -0
  103. modus_operandi/render.py +303 -0
  104. modus_operandi/tool_discovery.py +11 -0
  105. modus_operandi/uninstall.py +184 -0
  106. modus_operandi/verify.py +230 -0
  107. modus_operandi-0.1.0.dist-info/METADATA +140 -0
  108. modus_operandi-0.1.0.dist-info/RECORD +111 -0
  109. modus_operandi-0.1.0.dist-info/WHEEL +4 -0
  110. modus_operandi-0.1.0.dist-info/entry_points.txt +3 -0
  111. modus_operandi-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,24 @@
1
+ """modus-operandi package: the launcher and the install/bootstrap flow.
2
+
3
+ The package carries the pipeline artifacts as package data under
4
+ ``data/`` (pipeline scripts, prompts, workflows, the config example and the
5
+ victory sound) and renders them into the user config base on first run
6
+ (``modus_operandi.cli.ensure_installed``). ``__version__`` is mirrored in
7
+ ``pyproject.toml``; both are bumped together on release.
8
+ """
9
+
10
+ from .paths import Paths
11
+
12
+ # The package's public surface: the install layout type and the
13
+ # user-facing install failure. (The explicit __all__ marks Paths as a
14
+ # re-export of the paths.py alias; InstallError and __version__ are
15
+ # defined in this module.)
16
+ __all__ = ["InstallError", "Paths", "__version__"]
17
+
18
+ __version__ = "0.1.0"
19
+
20
+
21
+ class InstallError(Exception):
22
+ """User-facing failure of the install/bootstrap flow."""
23
+
24
+ pass
@@ -0,0 +1,52 @@
1
+ """Bootstrap: materialize the rendered installation on first run / update.
2
+
3
+ One responsibility: check the ``install-version.txt`` marker against the
4
+ package ``__version__`` and, when the rendered artifacts are missing or
5
+ stale, render them from the package data (prerequisites check + apply) and
6
+ report one status line. The launcher (cli.py) owns the argument parsing and
7
+ dispatch; this module owns the installation state — it changes together with
8
+ installer/verify, not with the command surface.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import sys
14
+
15
+ from . import InstallError, Paths, __version__, installer, verify
16
+
17
+
18
+ def ensure_installed(layout: Paths, check_prereqs: bool = True) -> int:
19
+ """Bootstrap the rendered installation from the package data.
20
+
21
+ Reads ``install-version.txt`` (config base /modus-operandi/install-version.txt):
22
+ when the file is missing or its version differs from the package
23
+ ``__version__``, checks the prerequisites and renders every artifact from
24
+ the package data (``apply()`` records the marker), then prints exactly
25
+ one status line. Returns 0 on success (including an up-to-date
26
+ installation) and 1 on error, with the reason on stderr.
27
+
28
+ ``check_prereqs=False`` skips the tool-presence checks: the `edit` flow
29
+ never runs the backend CLI, so a user who uninstalled it (or switched
30
+ configs) must still be able to edit the config and re-render the
31
+ artifacts.
32
+ """
33
+ marker = layout["install_version"]
34
+ if marker.exists() and marker.read_text(encoding="utf-8").strip() == __version__:
35
+ return 0
36
+ was_installed = marker.exists()
37
+ if check_prereqs:
38
+ try:
39
+ verify.check_prerequisites(layout)
40
+ except InstallError as exc:
41
+ print(f"error: {exc}", file=sys.stderr)
42
+ return 1
43
+ try:
44
+ installer.apply(layout)
45
+ except (InstallError, OSError, ValueError, KeyError) as exc:
46
+ print(f"error: {exc}", file=sys.stderr)
47
+ return 1
48
+ if was_installed:
49
+ print(f"modus-operandi: updated to {__version__}", flush=True)
50
+ else:
51
+ print(f"modus-operandi: installed to {layout['config_dir']}", flush=True)
52
+ return 0
modus_operandi/cli.py ADDED
@@ -0,0 +1,313 @@
1
+ #!/usr/bin/env python3
2
+ """modus-operandi - global launcher for the modus-operandi pipelines (console-script entry).
3
+
4
+ Installed by pip as the ``modus-operandi`` console script and callable from any
5
+ project directory, without `specify init` required. It delegates to the
6
+ installed run-pipeline.py wrapper with the absolute workflow path, so runs
7
+ keep the timestamps, live step output, run statistics and the victory sound.
8
+ `modus-operandi edit` opens the installed config.yml in your terminal editor and
9
+ applies it on save and close — a valid config is applied, an invalid one is
10
+ rolled back, an unchanged one is left as-is. `modus-operandi uninstall` removes
11
+ the rendered pipeline files from the machine.
12
+
13
+ The launcher resolves every installed path at runtime: run-pipeline.py, both
14
+ workflows and config.yml come from the config base directory derived from
15
+ XDG_CONFIG_HOME/$HOME. Nothing is baked into this file at install time.
16
+ Before dispatching a task/review/edit run, the launcher bootstraps the
17
+ installation (ensure_installed): when the rendered artifacts are missing or
18
+ stale, it renders them from the package data and prints one status line.
19
+
20
+ This module is the entry point of a module split, one concern per file: the
21
+ `edit` execution flow lives in edit_command.py, the shared editor resolution
22
+ in editor.py (the same editor.py the run-pipeline wrapper uses for its
23
+ feedback gate), the exceptions package ships one class per file, the
24
+ bootstrap (first-run/update render) lives in bootstrap.py and the
25
+ `uninstall` flow in uninstall.py.
26
+
27
+ Usage:
28
+ modus-operandi task "task description" [-i key=value ...]
29
+ modus-operandi review [--branch-diff]
30
+ modus-operandi edit
31
+ modus-operandi uninstall [--yes]
32
+ modus-operandi --backend cursor task "task description" # override the backend for this run
33
+ modus-operandi --help | -h
34
+
35
+ Examples:
36
+ modus-operandi task "add a dark mode toggle"
37
+ modus-operandi task "add a dark mode toggle" -i task_id=dark-mode
38
+ modus-operandi --backend cursor task "add a dark mode toggle"
39
+ modus-operandi review
40
+ modus-operandi review --branch-diff
41
+ modus-operandi edit
42
+ """
43
+
44
+ from __future__ import annotations
45
+
46
+ import os
47
+ import sys
48
+ from pathlib import Path
49
+ from typing import Any
50
+
51
+ from modus_operandi import bootstrap, paths, uninstall
52
+ from modus_operandi.edit_command import _run_edit
53
+ from modus_operandi.editor import resolve_editor
54
+ from modus_operandi.exceptions import (
55
+ EditRequested,
56
+ HelpRequested,
57
+ InvalidInvocation,
58
+ UninstallRequested,
59
+ )
60
+
61
+ # Public surface of the launcher: the pure mapping, the shared editor
62
+ # resolution and the signals, so tests and callers keep a single import
63
+ # target (the modus_operandi.cli module).
64
+ __all__ = [
65
+ "CONFIG",
66
+ "REVIEW_WORKFLOW",
67
+ "RUN_PIPELINE",
68
+ "TASK_WORKFLOW",
69
+ "USAGE",
70
+ "build_command",
71
+ "print_usage",
72
+ "resolve_editor",
73
+ ]
74
+
75
+
76
+ def _config_base() -> Path:
77
+ """The config root the installer writes into (XDG_CONFIG_HOME or ~/.config)."""
78
+ return Path(os.environ.get("XDG_CONFIG_HOME") or Path.home() / ".config")
79
+
80
+
81
+ def _scripts_dir() -> Path:
82
+ return _config_base() / "opencode" / "scripts"
83
+
84
+
85
+ def _config_dir() -> Path:
86
+ return _config_base() / "modus-operandi"
87
+
88
+
89
+ # Paths of the installed pipeline, derived from the config base at runtime
90
+ # (XDG_CONFIG_HOME or ~/.config), so nothing is baked at install time.
91
+ _LAYOUT = paths.build_paths_from_config_base(_config_base())
92
+ RUN_PIPELINE = str(_scripts_dir() / "run-pipeline.py")
93
+ REVIEW_WORKFLOW = str(_config_dir() / "review-pipeline.yml")
94
+ TASK_WORKFLOW = str(_config_dir() / "task-pipeline.yml")
95
+ CONFIG = str(_config_dir() / "config.yml")
96
+
97
+ USAGE = """Usage: modus-operandi <subcommand> [args]
98
+
99
+ modus-operandi --backend <opencode|cursor> <subcommand> [args]
100
+ Override the configured backend for this run (the flag must precede
101
+ the subcommand; the config `backend:` remains the default).
102
+
103
+ modus-operandi task "task description" [-i key=value ...]
104
+ Run the full task pipeline for the task (motivation study -> research ->
105
+ ADR -> implementation -> review). All non-flag arguments after `task`
106
+ are joined into the task input; -i key=value arguments are passed
107
+ through to the workflow.
108
+
109
+ modus-operandi review [--branch-diff] [-i key=value ...]
110
+ Review the project code (default: the whole codebase). With --branch-diff
111
+ only the changes between the current branch and the default branch.
112
+
113
+ modus-operandi edit
114
+ Open the installed config.yml in your terminal editor (VISUAL, then
115
+ EDITOR, then nano, then vi). On save and close it validates the file:
116
+ a valid config is applied, an invalid one is rolled back, and one that
117
+ was left unchanged is not re-applied.
118
+
119
+ modus-operandi uninstall [--yes]
120
+ Remove the rendered pipeline files (~/.config/modus-operandi and the
121
+ modus-operandi files under ~/.config/opencode), then run
122
+ `pip uninstall modus-operandi` to remove the package itself. Asks for
123
+ confirmation unless --yes is given.
124
+
125
+ modus-operandi --help | -h
126
+ Print this help and exit 0.
127
+
128
+ Examples:
129
+ modus-operandi task "add a dark mode toggle"
130
+ modus-operandi task "add a dark mode toggle" -i task_id=dark-mode
131
+ modus-operandi --backend cursor task "add a dark mode toggle"
132
+ modus-operandi review
133
+ modus-operandi review --branch-diff
134
+ modus-operandi edit
135
+ """
136
+
137
+
138
+ def build_command(argv: list[str]) -> list[str]:
139
+ """Map modus-operandi argv to the command to execute (pure dispatch).
140
+
141
+ Raises HelpRequested for `--help`/`-h`, EditRequested for `edit`,
142
+ UninstallRequested for `uninstall` (carrying the `--yes` flag) and
143
+ InvalidInvocation for an unusable invocation; it never prints anything,
144
+ so mapping stays separate from presentation. The caller owns the usage
145
+ output and the exit code. A leading global `--backend <opencode|cursor>`
146
+ flag (before the subcommand) is consumed and forwarded to
147
+ run-pipeline.py.
148
+ """
149
+ if not argv:
150
+ raise InvalidInvocation
151
+ backend: str | None = None
152
+ head, rest = argv[0], argv[1:]
153
+ if head == "--backend":
154
+ if not rest:
155
+ raise InvalidInvocation
156
+ backend = rest[0]
157
+ if backend not in ("opencode", "cursor"):
158
+ raise InvalidInvocation
159
+ rest = rest[1:]
160
+ if not rest:
161
+ raise InvalidInvocation
162
+ head, rest = rest[0], rest[1:]
163
+ if head in ("-h", "--help"):
164
+ raise HelpRequested
165
+ if head == "task":
166
+ return _build_task_command(rest, backend)
167
+ if head == "review":
168
+ return _build_review_command(rest, backend)
169
+ if head == "edit":
170
+ # `edit` takes no arguments: anything after the subcommand is ignored.
171
+ # Editor resolution is environment-dependent (os.environ, shutil.which)
172
+ # and belongs to the edit execution path (edit_command._run_edit), not
173
+ # to this pure mapping; a signal keeps the subcommand ->
174
+ # execution-path decision in one place.
175
+ raise EditRequested
176
+ if head == "uninstall":
177
+ # `--yes` skips the confirmation prompt of the uninstall flow; extra
178
+ # arguments are ignored like for `edit`.
179
+ raise UninstallRequested("--yes" in rest)
180
+ raise InvalidInvocation
181
+
182
+
183
+ def _build_text_input_command(
184
+ rest: list[str], backend: str | None, workflow: str, input_key: str
185
+ ) -> list[str]:
186
+ """Map the text-input subcommands (task) to a run-pipeline command.
187
+
188
+ The non-flag arguments are joined into a single text input
189
+ (`-i <input_key>=<joined text>`), while `-i key=value` pairs (the two
190
+ argv elements `-i` and `key=value`) and the combined single element
191
+ `"-i key=value"` are passed through to the workflow unchanged. A
192
+ dangling `-i` without a value is an invalid
193
+ invocation, not a flag to forward: specify would fail with a confusing
194
+ parser error, while other bad calls get a clear usage. The space in the
195
+ `-i ` prefix is required so text like `-integration` is not mistaken for
196
+ an input.
197
+ """
198
+ cmd = [RUN_PIPELINE]
199
+ if backend:
200
+ cmd += ["--backend", backend]
201
+ cmd.append(workflow)
202
+ text_parts: list[str] = []
203
+ passed: list[str] = []
204
+ i = 0
205
+ while i < len(rest):
206
+ arg = rest[i]
207
+ if arg == "-i":
208
+ if i + 1 >= len(rest):
209
+ # A dangling `-i` without a value is an invalid invocation,
210
+ # not a flag to forward: specify would fail with a confusing
211
+ # parser error, while other bad calls get a clear usage.
212
+ raise InvalidInvocation
213
+ # Shell-style pair: `-i key=value` as two argv elements.
214
+ passed.append(arg)
215
+ passed.append(rest[i + 1])
216
+ i += 2
217
+ continue
218
+ if arg.startswith("-i "):
219
+ # Combined single element: `-i key=value`. The space is required
220
+ # so text like `-integration` is not mistaken for an input.
221
+ passed.append(arg)
222
+ else:
223
+ text_parts.append(arg)
224
+ i += 1
225
+ text = " ".join(text_parts)
226
+ if not text:
227
+ raise InvalidInvocation
228
+ cmd.append("-i")
229
+ cmd.append(f"{input_key}={text}")
230
+ cmd.extend(passed)
231
+ return cmd
232
+
233
+
234
+ def _build_task_command(rest: list[str], backend: str | None = None) -> list[str]:
235
+ return _build_text_input_command(rest, backend, TASK_WORKFLOW, "task")
236
+
237
+
238
+ def _build_review_command(rest: list[str], backend: str | None = None) -> list[str]:
239
+ cmd = [RUN_PIPELINE]
240
+ if backend:
241
+ cmd += ["--backend", backend]
242
+ cmd.append(REVIEW_WORKFLOW)
243
+ i = 0
244
+ while i < len(rest):
245
+ arg = rest[i]
246
+ if arg == "--branch-diff":
247
+ cmd.append("-i")
248
+ cmd.append("branch-diff=true")
249
+ elif arg == "-i":
250
+ if i + 1 >= len(rest):
251
+ raise InvalidInvocation
252
+ cmd.append(arg)
253
+ cmd.append(rest[i + 1])
254
+ i += 2
255
+ continue
256
+ else:
257
+ cmd.append(arg)
258
+ i += 1
259
+ return cmd
260
+
261
+
262
+ def print_usage(stream: Any = None) -> None:
263
+ """Print the usage text; defaults to stdout, pass sys.stderr for errors."""
264
+ if stream is None:
265
+ stream = sys.stdout
266
+ print(USAGE, file=stream)
267
+
268
+
269
+ def main(argv: list[str] | None = None) -> int:
270
+ if argv is None:
271
+ argv = sys.argv[1:]
272
+ try:
273
+ try:
274
+ cmd = build_command(argv)
275
+ except HelpRequested:
276
+ print_usage()
277
+ return 0
278
+ except InvalidInvocation:
279
+ print_usage(sys.stderr)
280
+ return 1
281
+ except EditRequested:
282
+ # The edit flow never runs the backend CLI, so the
283
+ # tool-presence prerequisite check is skipped: a user who
284
+ # uninstalled the active backend must still be able to edit the
285
+ # config (e.g. to switch backends). The bootstrap render is
286
+ # best-effort on this path: a stale install marker with an
287
+ # INVALID config.yml must not gate the editor either — fixing a
288
+ # broken config is exactly what `edit` is for, and _run_edit
289
+ # re-validates when the editor closes (rolling back an invalid
290
+ # edit), so a failed bootstrap here cannot apply anything.
291
+ bootstrap.ensure_installed(_LAYOUT, check_prereqs=False)
292
+ return _run_edit(CONFIG, _LAYOUT)
293
+ except UninstallRequested as exc:
294
+ return uninstall.do_uninstall(_LAYOUT, exc.yes)
295
+ if bootstrap.ensure_installed(_LAYOUT) != 0:
296
+ return 1
297
+ try:
298
+ # os.execv replaces this process with run-pipeline.py, so its live
299
+ # output, timestamps and exit code pass through unchanged; it returns
300
+ # only when exec fails, which is why return 1 follows.
301
+ os.execv(cmd[0], cmd)
302
+ except OSError as exc:
303
+ print(f"error: cannot run {cmd[0]}: {exc}", file=sys.stderr)
304
+ return 1
305
+ except KeyboardInterrupt:
306
+ # Ctrl+C at a prompt (e.g. the uninstall confirmation) is a normal
307
+ # way to bail out: a quiet exit 130, not a traceback.
308
+ print("interrupted", file=sys.stderr)
309
+ return 130
310
+
311
+
312
+ if __name__ == "__main__":
313
+ sys.exit(main())
@@ -0,0 +1,199 @@
1
+ """Configuration: defaults, config.yml loading, merging and validation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ import yaml
10
+
11
+ from . import InstallError
12
+
13
+ DEFAULT_STATE_DIR = ".workflow"
14
+ DEFAULT_MAX_FIX_ITERATIONS = 5
15
+ DEFAULT_MAX_SRP_ITERATIONS = 5
16
+ DEFAULT_MAX_BUG_ITERATIONS = 5
17
+ DEFAULT_MAX_COMMENT_ITERATIONS = 5
18
+ DEFAULT_MAX_IMPLEMENT_ITERATIONS = 2
19
+ DEFAULT_MAX_QUESTIONS_ITERATIONS = 3
20
+ DEFAULT_SHELL_TIMEOUT = 7200
21
+ DEFAULT_REASONING = "max"
22
+ DEFAULT_ADR_DIR = "architecture"
23
+ DEFAULT_BACKEND = "opencode"
24
+ BACKENDS = ("opencode", "cursor")
25
+
26
+ DEFAULT_CONFIG: dict[str, Any] = {
27
+ "backend": DEFAULT_BACKEND,
28
+ "workflow": {
29
+ "state_dir": DEFAULT_STATE_DIR,
30
+ "max_fix_iterations": DEFAULT_MAX_FIX_ITERATIONS,
31
+ "max_srp_iterations": DEFAULT_MAX_SRP_ITERATIONS,
32
+ "max_bug_iterations": DEFAULT_MAX_BUG_ITERATIONS,
33
+ "max_comment_iterations": DEFAULT_MAX_COMMENT_ITERATIONS,
34
+ "max_implement_iterations": DEFAULT_MAX_IMPLEMENT_ITERATIONS,
35
+ "max_questions_iterations": DEFAULT_MAX_QUESTIONS_ITERATIONS,
36
+ "shell_timeout": DEFAULT_SHELL_TIMEOUT,
37
+ "adr_dir": DEFAULT_ADR_DIR,
38
+ "human_gates": True,
39
+ "use_serve": False,
40
+ },
41
+ }
42
+
43
+
44
+ def load_config(path: str | Path) -> dict[str, Any]:
45
+ # Config loading is the one place a YAML parse error can surface from user
46
+ # input, so it is converted to InstallError here — main() then stays flat
47
+ # without special-casing yaml types. A root that is valid YAML but not a
48
+ # mapping (a bare scalar or a list) is the same class of user error, and
49
+ # dict(raw) would otherwise raise an unhandled TypeError instead.
50
+ # PyYAML is a declared wheel dependency (pyproject.toml `dependencies`),
51
+ # so the import is unconditional.
52
+ with open(path, encoding="utf-8") as fh:
53
+ text = fh.read()
54
+ try:
55
+ raw = yaml.safe_load(text) or {}
56
+ except yaml.YAMLError as exc:
57
+ raise InstallError(f"invalid config.yml: {exc}") from exc
58
+ if not isinstance(raw, dict):
59
+ raise InstallError("invalid config.yml: top-level must be a mapping")
60
+ return dict(raw)
61
+
62
+
63
+ def opencode_models_complete(cfg: dict[str, Any]) -> bool:
64
+ """Whether the opencode section carries usable models for both roles.
65
+
66
+ The single predicate under which render.render_agents() writes the
67
+ opencode agent files; verify.check_files() requires those files under
68
+ the same condition, so a config whose opencode section is empty or
69
+ incomplete (valid when another backend is active) neither renders nor
70
+ demands the agent files.
71
+ """
72
+ models = (cfg.get("opencode") or {}).get("models") or {}
73
+ planner = models.get("planner") or {}
74
+ executor = models.get("executor") or {}
75
+ return bool(
76
+ planner.get("provider")
77
+ and planner.get("model")
78
+ and executor.get("provider")
79
+ and executor.get("model")
80
+ )
81
+
82
+
83
+ def apply_defaults(raw: Any) -> dict[str, Any]:
84
+ # Same guard as load_config: raw may be None (treated as empty config) but
85
+ # any other non-mapping top level is a user error, not a TypeError.
86
+ if raw is not None and not isinstance(raw, dict):
87
+ raise InstallError("invalid config.yml: top-level must be a mapping")
88
+ cfg = dict(raw or {})
89
+ # Legacy top-level `models:` is folded into `opencode.models` so existing
90
+ # configs keep working; an explicit opencode section wins over the legacy
91
+ # key (the user is mid-migration and opencode is the section they wrote).
92
+ if "models" in cfg:
93
+ if "opencode" not in cfg:
94
+ cfg["opencode"] = {"models": cfg["models"]}
95
+ del cfg["models"]
96
+ cfg.setdefault("backend", DEFAULT_BACKEND)
97
+ workflow = cfg.get("workflow") or {}
98
+ if not isinstance(workflow, dict):
99
+ raise InstallError("invalid config.yml: workflow must be a mapping")
100
+ merged_workflow = dict(DEFAULT_CONFIG["workflow"])
101
+ merged_workflow.update(workflow)
102
+ cfg["workflow"] = merged_workflow
103
+ if "opencode" in cfg:
104
+ opencode = cfg["opencode"]
105
+ if not isinstance(opencode, dict):
106
+ raise InstallError("invalid config.yml: opencode must be a mapping")
107
+ models = opencode.get("models") or {}
108
+ if not isinstance(models, dict):
109
+ raise InstallError("invalid config.yml: opencode.models must be a mapping")
110
+ for role in ("planner", "executor"):
111
+ model = models.get(role) or {}
112
+ if not isinstance(model, dict):
113
+ raise InstallError(f"invalid config.yml: opencode.models.{role} must be a mapping")
114
+ model = dict(model)
115
+ model.setdefault("reasoning", DEFAULT_REASONING)
116
+ models[role] = model
117
+ opencode["models"] = models
118
+ cfg["opencode"] = opencode
119
+ if "cursor" in cfg:
120
+ cursor = cfg["cursor"]
121
+ if not isinstance(cursor, dict):
122
+ raise InstallError("invalid config.yml: cursor must be a mapping")
123
+ models = cursor.get("models") or {}
124
+ if not isinstance(models, dict):
125
+ raise InstallError("invalid config.yml: cursor.models must be a mapping")
126
+ for role in ("planner", "executor"):
127
+ model = models.get(role) or {}
128
+ if not isinstance(model, dict):
129
+ raise InstallError(f"invalid config.yml: cursor.models.{role} must be a mapping")
130
+ models[role] = model
131
+ cursor["models"] = models
132
+ cfg["cursor"] = cursor
133
+ return cfg
134
+
135
+
136
+ def validate_config(cfg: dict[str, Any]) -> dict[str, Any]:
137
+ errors: list[str] = []
138
+ backend = cfg.get("backend")
139
+ if backend not in BACKENDS:
140
+ errors.append(f"backend must be one of: opencode, cursor (got {backend!r})")
141
+ # Only the active backend's models are strictly required (symmetric to
142
+ # the opencode provider/model/reasoning fields); the inactive section, if
143
+ # present, was already validated structurally by apply_defaults.
144
+ if backend == "opencode":
145
+ models = (cfg.get("opencode") or {}).get("models") or {}
146
+ for role in ("planner", "executor"):
147
+ model = models.get(role) or {}
148
+ for key in ("provider", "model", "reasoning"):
149
+ value = model.get(key)
150
+ if not value:
151
+ errors.append(f"missing required key: opencode.models.{role}.{key}")
152
+ elif "<" in str(value) or ">" in str(value):
153
+ errors.append(
154
+ f"placeholder value in opencode.models.{role}.{key} - edit config.yml first"
155
+ )
156
+ elif backend == "cursor":
157
+ models = (cfg.get("cursor") or {}).get("models") or {}
158
+ for role in ("planner", "executor"):
159
+ model = models.get(role) or {}
160
+ value = model.get("model")
161
+ if not value:
162
+ errors.append(f"missing required key: cursor.models.{role}.model")
163
+ elif "<" in str(value) or ">" in str(value):
164
+ errors.append(
165
+ f"placeholder value in cursor.models.{role}.model - edit config.yml first"
166
+ )
167
+ workflow = cfg["workflow"]
168
+ for key in (
169
+ "max_fix_iterations",
170
+ "max_srp_iterations",
171
+ "max_bug_iterations",
172
+ "max_comment_iterations",
173
+ "max_implement_iterations",
174
+ "max_questions_iterations",
175
+ ):
176
+ value = workflow.get(key)
177
+ # type() is int, not isinstance: bool is a subclass of int, so
178
+ # `max_fix_iterations: true` must not pass the integer check — it
179
+ # would render as "True" into the generated workflows.
180
+ if type(value) is not int or value < 1:
181
+ errors.append(f"workflow.{key} must be an integer >= 1")
182
+ value = workflow.get("shell_timeout")
183
+ if type(value) is not int or value < 1:
184
+ errors.append("workflow.shell_timeout must be a positive number of seconds")
185
+ for key in ("state_dir", "adr_dir"):
186
+ value = workflow.get(key, "")
187
+ # The regex below is only meaningful for strings: str(None) is "None"
188
+ # and would pass it, silently pointing the workflow at "None/..." dirs.
189
+ if not isinstance(value, str):
190
+ errors.append(f"workflow.{key} must be a string")
191
+ elif not re.fullmatch(r"[A-Za-z0-9_./-]+", value):
192
+ errors.append(f"workflow.{key} contains unsupported characters")
193
+ if not isinstance(workflow.get("human_gates"), bool):
194
+ errors.append("workflow.human_gates must be a boolean")
195
+ if not isinstance(workflow.get("use_serve"), bool):
196
+ errors.append("workflow.use_serve must be a boolean")
197
+ if errors:
198
+ raise InstallError("invalid config.yml:\n " + "\n ".join(errors))
199
+ return cfg
@@ -0,0 +1,37 @@
1
+ # modus-operandi configuration
2
+ # On first run this file is copied to ~/.config/modus-operandi/config.yml.
3
+ # Edit the values below, then re-apply with `modus-operandi edit` (dev: re-run install.py).
4
+
5
+ backend: opencode # active backend: opencode or cursor (per-run override: modus-operandi --backend cursor)
6
+
7
+ opencode: # models for the opencode backend (legacy top-level `models:` is read as this section)
8
+ models:
9
+ planner:
10
+ provider: opencode # opencode provider id: `opencode` = OpenCode Zen (free models work with no setup),
11
+ # `opencode-go` = OpenCode Go ($10/month subscription, OPENCODE_API_KEY required)
12
+ model: big-pickle # strong model for planning and review (free on Zen; paid: deepseek-v4-pro, gpt-5.x, ...)
13
+ reasoning: max # reasoning effort passed to the provider (e.g. minimal, low, high, max)
14
+ executor:
15
+ provider: opencode
16
+ model: big-pickle # model for implementation (free on Zen; paid: deepseek-v4-flash, ...)
17
+ reasoning: max # reasoning effort passed to the provider
18
+
19
+ cursor: # models for the cursor backend (passed to cursor-agent as --model <slug>)
20
+ models:
21
+ planner:
22
+ model: composer-2 # strong model for planning and review (Cursor model slug)
23
+ executor:
24
+ model: composer-2 # cheap/fast model for implementation (Cursor model slug)
25
+
26
+ workflow:
27
+ state_dir: .workflow # task artifact directory inside a project
28
+ max_fix_iterations: 5 # ceiling for the unified parallel review-fix loop (SRP, bugs, general review, comments, ADR-0009)
29
+ max_srp_iterations: 5 # legacy: no longer binds a separate SRP loop (kept for backward compatibility)
30
+ max_bug_iterations: 5 # legacy: no longer binds a separate bug loop (kept for backward compatibility)
31
+ max_comment_iterations: 5 # legacy: no longer binds a separate comment loop (kept for backward compatibility)
32
+ max_implement_iterations: 2 # ceiling for the implement verify/retry loop (guard against an empty implementation)
33
+ max_questions_iterations: 3 # ceiling for the task-pipeline executor questions loop
34
+ shell_timeout: 7200 # per-step timeout in seconds for agent steps (2h)
35
+ adr_dir: architecture # directory (repo-relative) where approved ADRs are saved
36
+ human_gates: true # false = gates auto-approve (non-interactive runs)
37
+ use_serve: false # true = run-agent.sh passes --attach http://localhost:4096
@@ -0,0 +1,32 @@
1
+ #!/usr/bin/env bash
2
+ # adr-task-id.sh - task-pipeline generate-task-id step.
3
+ #
4
+ # Uses the explicit task id when given; otherwise asks the executor for a
5
+ # short English kebab-case slug (name-task.sh, one-shot) and appends a
6
+ # timestamp. Creates the task dir and points <state_dir>/tasks/current at it.
7
+ # The workflow never embeds shell logic (CONTRIBUTING.md), so the id
8
+ # derivation lives here. The task_id value was already validated by
9
+ # validate_inputs.py (validate-task-id step) before it reaches this script.
10
+ #
11
+ # Usage: adr-task-id.sh <state_dir> <task_id> <feature>
12
+ set -euo pipefail
13
+
14
+ [ $# -eq 3 ] || { echo "usage: $0 <state_dir> <task_id> <feature>" >&2; exit 2; }
15
+ STATE_DIR="$1"
16
+ TASK_ID="$2"
17
+ FEATURE="$3"
18
+
19
+ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
20
+
21
+ mkdir -p "$STATE_DIR/tasks"
22
+ if [ -n "$TASK_ID" ]; then
23
+ tid="$TASK_ID"
24
+ else
25
+ slug=$("$SCRIPT_DIR/name-task.sh" "$FEATURE")
26
+ slug=$(printf '%s' "$slug" | tr '[:upper:]' '[:lower:]' | sed -E 's/[^a-z0-9]+/-/g; s/^-+//; s/-+$//' | cut -c1-40)
27
+ [ -n "$slug" ] || slug=task
28
+ tid="$slug-$(date +%Y%m%d-%H%M)"
29
+ fi
30
+ mkdir -p "$STATE_DIR/tasks/$tid"
31
+ ln -sfn "$tid" "$STATE_DIR/tasks/current"
32
+ echo "task id: $tid"