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.
- modus_operandi/__init__.py +24 -0
- modus_operandi/bootstrap.py +52 -0
- modus_operandi/cli.py +313 -0
- modus_operandi/config.py +199 -0
- modus_operandi/data/config.example.yml +37 -0
- modus_operandi/data/pipeline_scripts/adr-task-id.sh +32 -0
- modus_operandi/data/pipeline_scripts/adr_utils.py +74 -0
- modus_operandi/data/pipeline_scripts/agent-step.sh +38 -0
- modus_operandi/data/pipeline_scripts/agent_call.py +34 -0
- modus_operandi/data/pipeline_scripts/agent_log_tailer.py +121 -0
- modus_operandi/data/pipeline_scripts/buffered_emitter.py +156 -0
- modus_operandi/data/pipeline_scripts/check_implementation.py +109 -0
- modus_operandi/data/pipeline_scripts/check_plan_deviation.py +43 -0
- modus_operandi/data/pipeline_scripts/check_questions.py +55 -0
- modus_operandi/data/pipeline_scripts/check_review.py +149 -0
- modus_operandi/data/pipeline_scripts/clear-feedback.sh +12 -0
- modus_operandi/data/pipeline_scripts/config_invocation.py +197 -0
- modus_operandi/data/pipeline_scripts/determine-scope.sh +57 -0
- modus_operandi/data/pipeline_scripts/display.py +89 -0
- modus_operandi/data/pipeline_scripts/editor.py +97 -0
- modus_operandi/data/pipeline_scripts/engine_output.py +95 -0
- modus_operandi/data/pipeline_scripts/feedback_editor.py +136 -0
- modus_operandi/data/pipeline_scripts/feedback_gate.py +86 -0
- modus_operandi/data/pipeline_scripts/gate_state.py +89 -0
- modus_operandi/data/pipeline_scripts/implement-pass-check.sh +23 -0
- modus_operandi/data/pipeline_scripts/implement-retry.sh +25 -0
- modus_operandi/data/pipeline_scripts/latency_table.py +223 -0
- modus_operandi/data/pipeline_scripts/live_lines.py +339 -0
- modus_operandi/data/pipeline_scripts/live_monitor.py +201 -0
- modus_operandi/data/pipeline_scripts/name-task.sh +90 -0
- modus_operandi/data/pipeline_scripts/notify.py +55 -0
- modus_operandi/data/pipeline_scripts/pass-check.sh +27 -0
- modus_operandi/data/pipeline_scripts/prompt_subst.sh +20 -0
- modus_operandi/data/pipeline_scripts/pty_spawn.py +155 -0
- modus_operandi/data/pipeline_scripts/review-check.sh +83 -0
- modus_operandi/data/pipeline_scripts/review-task-id.sh +22 -0
- modus_operandi/data/pipeline_scripts/run-agent-cursor.sh +208 -0
- modus_operandi/data/pipeline_scripts/run-agent.sh +268 -0
- modus_operandi/data/pipeline_scripts/run_finish.py +102 -0
- modus_operandi/data/pipeline_scripts/run_id_discoverer.py +30 -0
- modus_operandi/data/pipeline_scripts/run_pipeline.py +283 -0
- modus_operandi/data/pipeline_scripts/run_state.py +33 -0
- modus_operandi/data/pipeline_scripts/run_statistics.py +274 -0
- modus_operandi/data/pipeline_scripts/save_adr.py +130 -0
- modus_operandi/data/pipeline_scripts/session_store.sh +137 -0
- modus_operandi/data/pipeline_scripts/show-file.sh +38 -0
- modus_operandi/data/pipeline_scripts/stdout_reader.py +117 -0
- modus_operandi/data/pipeline_scripts/step_result_poller.py +62 -0
- modus_operandi/data/pipeline_scripts/table_format.py +103 -0
- modus_operandi/data/pipeline_scripts/task_utils.py +25 -0
- modus_operandi/data/pipeline_scripts/usage_parser.py +142 -0
- modus_operandi/data/pipeline_scripts/validate_inputs.py +114 -0
- modus_operandi/data/pipeline_scripts/warm-planner.sh +47 -0
- modus_operandi/data/pipeline_scripts/workflow_info.py +89 -0
- modus_operandi/data/pipeline_scripts/wrapper_cli.py +57 -0
- modus_operandi/data/prompts/adr/bug-review.md +3 -0
- modus_operandi/data/prompts/adr/comment-fix.md +1 -0
- modus_operandi/data/prompts/adr/comment-review.md +3 -0
- modus_operandi/data/prompts/adr/executor-questions.md +1 -0
- modus_operandi/data/prompts/adr/implement-continue.md +1 -0
- modus_operandi/data/prompts/adr/implement-retry.md +1 -0
- modus_operandi/data/prompts/adr/implement.md +1 -0
- modus_operandi/data/prompts/adr/planner-agreement.md +1 -0
- modus_operandi/data/prompts/adr/planner-answers.md +1 -0
- modus_operandi/data/prompts/adr/review.md +3 -0
- modus_operandi/data/prompts/adr/srp-review.md +40 -0
- modus_operandi/data/prompts/bug-fix.md +1 -0
- modus_operandi/data/prompts/fix-all.md +7 -0
- modus_operandi/data/prompts/fix.md +1 -0
- modus_operandi/data/prompts/review/bug-rereview.md +1 -0
- modus_operandi/data/prompts/review/bug-review.md +1 -0
- modus_operandi/data/prompts/review/comment-fix.md +1 -0
- modus_operandi/data/prompts/review/comment-rereview.md +1 -0
- modus_operandi/data/prompts/review/comment-review.md +1 -0
- modus_operandi/data/prompts/review/review-rereview.md +1 -0
- modus_operandi/data/prompts/review/review.md +1 -0
- modus_operandi/data/prompts/review/srp-rereview.md +25 -0
- modus_operandi/data/prompts/review/srp-review.md +42 -0
- modus_operandi/data/prompts/review/warmup.md +12 -0
- modus_operandi/data/prompts/srp-fix.md +1 -0
- modus_operandi/data/prompts/task/research.md +1 -0
- modus_operandi/data/prompts/task/study-revise.md +1 -0
- modus_operandi/data/prompts/task/study.md +1 -0
- modus_operandi/data/prompts/task/write-adr.md +99 -0
- modus_operandi/data/prompts/task/write-plan.md +1 -0
- modus_operandi/data/roles/executor.md +14 -0
- modus_operandi/data/roles/planner.md +13 -0
- modus_operandi/data/victory.wav +0 -0
- modus_operandi/data/workflows/review-pipeline.yml +107 -0
- modus_operandi/data/workflows/task-pipeline.yml +341 -0
- modus_operandi/edit_command.py +113 -0
- modus_operandi/editor.py +97 -0
- modus_operandi/exceptions/__init__.py +17 -0
- modus_operandi/exceptions/edit_requested.py +6 -0
- modus_operandi/exceptions/help_requested.py +5 -0
- modus_operandi/exceptions/invalid_invocation.py +6 -0
- modus_operandi/exceptions/uninstall_requested.py +12 -0
- modus_operandi/installer.py +74 -0
- modus_operandi/installer_cli.py +165 -0
- modus_operandi/paths.py +117 -0
- modus_operandi/proc.py +25 -0
- modus_operandi/prompt.py +12 -0
- modus_operandi/render.py +303 -0
- modus_operandi/tool_discovery.py +11 -0
- modus_operandi/uninstall.py +184 -0
- modus_operandi/verify.py +230 -0
- modus_operandi-0.1.0.dist-info/METADATA +140 -0
- modus_operandi-0.1.0.dist-info/RECORD +111 -0
- modus_operandi-0.1.0.dist-info/WHEEL +4 -0
- modus_operandi-0.1.0.dist-info/entry_points.txt +3 -0
- 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())
|
modus_operandi/config.py
ADDED
|
@@ -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"
|