subtask-mcp 0.2.11__tar.gz → 0.4.4__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (95) hide show
  1. {subtask_mcp-0.2.11/subtask_mcp.egg-info → subtask_mcp-0.4.4}/PKG-INFO +38 -5
  2. {subtask_mcp-0.2.11 → subtask_mcp-0.4.4}/README.md +37 -4
  3. {subtask_mcp-0.2.11 → subtask_mcp-0.4.4}/subtask/__init__.py +1 -1
  4. subtask_mcp-0.4.4/subtask/ast_merge.py +124 -0
  5. subtask_mcp-0.4.4/subtask/bisect_tool.py +141 -0
  6. {subtask_mcp-0.2.11 → subtask_mcp-0.4.4}/subtask/cli.py +48 -1
  7. subtask_mcp-0.4.4/subtask/context.py +140 -0
  8. subtask_mcp-0.4.4/subtask/core.py +1142 -0
  9. {subtask_mcp-0.2.11 → subtask_mcp-0.4.4}/subtask/fastserver.py +34 -3
  10. subtask_mcp-0.4.4/subtask/groups.py +380 -0
  11. {subtask_mcp-0.2.11 → subtask_mcp-0.4.4}/subtask/install/commands.py +30 -0
  12. subtask_mcp-0.4.4/subtask/integrate.py +180 -0
  13. subtask_mcp-0.4.4/subtask/invariants.py +162 -0
  14. subtask_mcp-0.4.4/subtask/map_reduce.py +72 -0
  15. subtask_mcp-0.4.4/subtask/memory.py +145 -0
  16. subtask_mcp-0.4.4/subtask/oracle.py +80 -0
  17. {subtask_mcp-0.2.11 → subtask_mcp-0.4.4}/subtask/router.py +28 -4
  18. subtask_mcp-0.4.4/subtask/server.py +301 -0
  19. {subtask_mcp-0.2.11 → subtask_mcp-0.4.4}/subtask/skill/SKILL.md +44 -0
  20. {subtask_mcp-0.2.11 → subtask_mcp-0.4.4}/subtask/telemetry.py +30 -0
  21. subtask_mcp-0.4.4/subtask/tools.json +473 -0
  22. subtask_mcp-0.4.4/subtask/verifier.py +169 -0
  23. subtask_mcp-0.4.4/subtask/watchdog.py +56 -0
  24. {subtask_mcp-0.2.11 → subtask_mcp-0.4.4}/subtask/worker.py +105 -4
  25. {subtask_mcp-0.2.11 → subtask_mcp-0.4.4/subtask_mcp.egg-info}/PKG-INFO +38 -5
  26. {subtask_mcp-0.2.11 → subtask_mcp-0.4.4}/subtask_mcp.egg-info/SOURCES.txt +24 -1
  27. subtask_mcp-0.4.4/tests/test_board.py +105 -0
  28. subtask_mcp-0.4.4/tests/test_context_dag.py +139 -0
  29. subtask_mcp-0.4.4/tests/test_falsify_040.py +156 -0
  30. subtask_mcp-0.4.4/tests/test_falsify_tappe.py +260 -0
  31. subtask_mcp-0.4.4/tests/test_fanout_consensus.py +149 -0
  32. subtask_mcp-0.4.4/tests/test_guidance.py +101 -0
  33. subtask_mcp-0.4.4/tests/test_invariants_ast.py +215 -0
  34. subtask_mcp-0.4.4/tests/test_map_engrams.py +97 -0
  35. subtask_mcp-0.4.4/tests/test_oracle_bisect.py +121 -0
  36. subtask_mcp-0.4.4/tests/test_plan_gaps.py +50 -0
  37. {subtask_mcp-0.2.11 → subtask_mcp-0.4.4}/tests/test_protocol.py +1 -1
  38. {subtask_mcp-0.2.11 → subtask_mcp-0.4.4}/tests/test_schema.py +1 -1
  39. subtask_mcp-0.4.4/tests/test_steer_wait.py +178 -0
  40. {subtask_mcp-0.2.11 → subtask_mcp-0.4.4}/tests/test_subtask.py +1 -1
  41. subtask_mcp-0.4.4/tests/test_worktree_verify.py +158 -0
  42. subtask_mcp-0.2.11/subtask/core.py +0 -610
  43. subtask_mcp-0.2.11/subtask/server.py +0 -194
  44. subtask_mcp-0.2.11/subtask/tools.json +0 -237
  45. {subtask_mcp-0.2.11 → subtask_mcp-0.4.4}/LICENSE +0 -0
  46. {subtask_mcp-0.2.11 → subtask_mcp-0.4.4}/pyproject.toml +0 -0
  47. {subtask_mcp-0.2.11 → subtask_mcp-0.4.4}/setup.cfg +0 -0
  48. {subtask_mcp-0.2.11 → subtask_mcp-0.4.4}/subtask/admission.py +0 -0
  49. {subtask_mcp-0.2.11 → subtask_mcp-0.4.4}/subtask/capacity.py +0 -0
  50. {subtask_mcp-0.2.11 → subtask_mcp-0.4.4}/subtask/config.py +0 -0
  51. {subtask_mcp-0.2.11 → subtask_mcp-0.4.4}/subtask/doctor.py +0 -0
  52. {subtask_mcp-0.2.11 → subtask_mcp-0.4.4}/subtask/engines/__init__.py +0 -0
  53. {subtask_mcp-0.2.11 → subtask_mcp-0.4.4}/subtask/engines/base.py +0 -0
  54. {subtask_mcp-0.2.11 → subtask_mcp-0.4.4}/subtask/engines/claude_api.py +0 -0
  55. {subtask_mcp-0.2.11 → subtask_mcp-0.4.4}/subtask/engines/codex.py +0 -0
  56. {subtask_mcp-0.2.11 → subtask_mcp-0.4.4}/subtask/engines/demo.py +0 -0
  57. {subtask_mcp-0.2.11 → subtask_mcp-0.4.4}/subtask/engines/dsh.py +0 -0
  58. {subtask_mcp-0.2.11 → subtask_mcp-0.4.4}/subtask/engines/kimi.py +0 -0
  59. {subtask_mcp-0.2.11 → subtask_mcp-0.4.4}/subtask/engines/opencode.py +0 -0
  60. {subtask_mcp-0.2.11 → subtask_mcp-0.4.4}/subtask/engines/registry.py +0 -0
  61. {subtask_mcp-0.2.11 → subtask_mcp-0.4.4}/subtask/events.py +0 -0
  62. {subtask_mcp-0.2.11 → subtask_mcp-0.4.4}/subtask/evidence.py +0 -0
  63. {subtask_mcp-0.2.11 → subtask_mcp-0.4.4}/subtask/gc.py +0 -0
  64. {subtask_mcp-0.2.11 → subtask_mcp-0.4.4}/subtask/install/__init__.py +0 -0
  65. {subtask_mcp-0.2.11 → subtask_mcp-0.4.4}/subtask/install/claude_plugin.py +0 -0
  66. {subtask_mcp-0.2.11 → subtask_mcp-0.4.4}/subtask/install/clients.py +0 -0
  67. {subtask_mcp-0.2.11 → subtask_mcp-0.4.4}/subtask/install/doctor.py +0 -0
  68. {subtask_mcp-0.2.11 → subtask_mcp-0.4.4}/subtask/install/fileops.py +0 -0
  69. {subtask_mcp-0.2.11 → subtask_mcp-0.4.4}/subtask/install/installer.py +0 -0
  70. {subtask_mcp-0.2.11 → subtask_mcp-0.4.4}/subtask/install/ownership.py +0 -0
  71. {subtask_mcp-0.2.11 → subtask_mcp-0.4.4}/subtask/install/tomledit.py +0 -0
  72. {subtask_mcp-0.2.11 → subtask_mcp-0.4.4}/subtask/jobs.py +0 -0
  73. {subtask_mcp-0.2.11 → subtask_mcp-0.4.4}/subtask/ledger.py +0 -0
  74. {subtask_mcp-0.2.11 → subtask_mcp-0.4.4}/subtask/narrate.py +0 -0
  75. {subtask_mcp-0.2.11 → subtask_mcp-0.4.4}/subtask/policy.py +0 -0
  76. {subtask_mcp-0.2.11 → subtask_mcp-0.4.4}/subtask/procid.py +0 -0
  77. {subtask_mcp-0.2.11 → subtask_mcp-0.4.4}/subtask/progress.py +0 -0
  78. {subtask_mcp-0.2.11 → subtask_mcp-0.4.4}/subtask/redact.py +0 -0
  79. {subtask_mcp-0.2.11 → subtask_mcp-0.4.4}/subtask/roi.py +0 -0
  80. {subtask_mcp-0.2.11 → subtask_mcp-0.4.4}/subtask/runners.py +0 -0
  81. {subtask_mcp-0.2.11 → subtask_mcp-0.4.4}/subtask/schemacheck.py +0 -0
  82. {subtask_mcp-0.2.11 → subtask_mcp-0.4.4}/subtask/stephook.py +0 -0
  83. {subtask_mcp-0.2.11 → subtask_mcp-0.4.4}/subtask_mcp.egg-info/dependency_links.txt +0 -0
  84. {subtask_mcp-0.2.11 → subtask_mcp-0.4.4}/subtask_mcp.egg-info/entry_points.txt +0 -0
  85. {subtask_mcp-0.2.11 → subtask_mcp-0.4.4}/subtask_mcp.egg-info/requires.txt +0 -0
  86. {subtask_mcp-0.2.11 → subtask_mcp-0.4.4}/subtask_mcp.egg-info/top_level.txt +0 -0
  87. {subtask_mcp-0.2.11 → subtask_mcp-0.4.4}/tests/test_concurrency.py +0 -0
  88. {subtask_mcp-0.2.11 → subtask_mcp-0.4.4}/tests/test_engines.py +0 -0
  89. {subtask_mcp-0.2.11 → subtask_mcp-0.4.4}/tests/test_falsify.py +0 -0
  90. {subtask_mcp-0.2.11 → subtask_mcp-0.4.4}/tests/test_independent_client.py +0 -0
  91. {subtask_mcp-0.2.11 → subtask_mcp-0.4.4}/tests/test_install.py +0 -0
  92. {subtask_mcp-0.2.11 → subtask_mcp-0.4.4}/tests/test_live.py +0 -0
  93. {subtask_mcp-0.2.11 → subtask_mcp-0.4.4}/tests/test_routing_results.py +0 -0
  94. {subtask_mcp-0.2.11 → subtask_mcp-0.4.4}/tests/test_security.py +0 -0
  95. {subtask_mcp-0.2.11 → subtask_mcp-0.4.4}/tests/test_stephook.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: subtask-mcp
3
- Version: 0.2.11
3
+ Version: 0.4.4
4
4
  Summary: Background sub-agents for Claude Code, Codex, Kimi and OpenCode, over MCP or CLI, with a spend cap
5
5
  License: MIT
6
6
  Project-URL: Homepage, https://github.com/tedin7/subtask
@@ -111,15 +111,48 @@ redaction is a second layer, not a guarantee. See [SECURITY.md](SECURITY.md).
111
111
 
112
112
  | tool | what it does |
113
113
  |---|---|
114
- | `start(task, kind, repo, cwd, files, readonly, engine, max_usd, effort, base, max_turns, policy, verify)` | start one job |
115
- | `start_many(tasks)` | start several; partial success is listed per item |
116
- | `wait(ids, timeout=300, any=False, since=None)` | block until they end; progress notifications while waiting; `since` cursor for clients without progress |
114
+ | `start(task, kind, repo, cwd, files, readonly, engine, max_usd, effort, base, max_turns, policy, verify, isolation, max_retries, adversarial, context_refs, auto_repomap, tier, invariants, allowed_paths, symbol_scope, oracle_cmd)` | start one job (see below) |
115
+ | `start_many(tasks)` | start several; partial success is listed per item. Items with `id` and `depends_on` form a group (`grp-…`) run as a DAG without extra turns; `foreach=<parent id>` makes one copy per item of the parent's `.subtask/fanout.json` (at most 8), and dependents wait for every copy; `after="any"` starts when one parent succeeded |
116
+ | `integrate(ids, action="apply", auto_resolve=False)` | worktree jobs back: `diff`, `apply` (all or nothing, uncommitted), `discard`; `auto_resolve` starts a reconciliation job on conflict |
117
+ | `consensus(prompt, engines, verify_cmd, isolation="worktree")` | 2 to 4 engines solve it blind in their own worktrees, then one job compares their diffs as anonymous patches and writes one solution, checked by `verify_cmd` and the invariants; returns a group id |
118
+ | `bisect(good_ref, bad_ref, test_cmd, parallelism=4, auto_fix=False)` | first commit that makes `test_cmd` fail, testing up to `parallelism` (max 8) commits per round in temporary worktrees; returns the commit, author and diff; `auto_fix` starts a verified fix job |
119
+ | `map_reduce(glob_pattern, prompt_template, batch_size=5, concurrency=3, verify_cmd, isolation="worktree")` | one job per batch of files (`{{files}}` in the template), at most `concurrency` at a time; returns a group id |
120
+ | `wait(ids, timeout=300, mode="all", since=None, stall_timeout=None)` | block until they end (`any`: the first; `first_success`: the first done and verified, the others are stopped); returns early on a stalled job; progress notifications while waiting; `since` cursor for clients without progress |
121
+ | `steer(id, action, instruction=None)` | `hint` (queued, sent as a reply when the attempt ends), `abort`, `retry` (fresh job with the instruction) |
122
+ | `inspect(id, tail=80, grep=None)` | raw log lines, only when the summary is not enough |
117
123
  | `status(id)` / `result(id, full, evidence)` | one line / summary or full text or JSON envelope |
118
124
  | `reply(id, message)` / `stop(id)` / `cleanup(id)` | continue / stop the work / remove a clean worktree (branch kept) |
119
125
  | `engines()` | capacity per engine with source, freshness and confidence, plus spend |
120
126
  | `step(title, path, how, decisions)` | an independent cheap model narrates the step from the repo's real state |
121
127
 
122
- CLI only: `subtask doctor`, `gc [--apply]`, `du`, `roi`, `routing explain|calibrate`,
128
+ Options of `start`:
129
+
130
+ - `isolation="worktree"`: own worktree and branch (like `repo=`), plus `SUBTASK_PORT` (from 18100),
131
+ `SUBTASK_ID` and a private `TMPDIR`. The result lists `files_modified` and `diff_stat`.
132
+ - `verify` + `max_retries`: a failed check resumes the job with the output; when every retry fails, a
133
+ worktree goes back to its best attempt (scored from "N passed / N failed").
134
+ - `adversarial=True`: a Breaker job writes edge-case tests from the spec only; they are copied into the
135
+ Builder's worktree and run with `verify`.
136
+ - `context_refs`: `file:PATH[:A-B]`, `git:diff[:REF]`, `subtask:ID:summary|diff`, resolved before start.
137
+ `auto_repomap=True` adds tracked files with their top-level symbols (cached per HEAD).
138
+ - `invariants` (default `["no_test_deletion", "no_suppression"]`, `[]` turns them off): before `verify` counts,
139
+ test functions may not decrease, no new skip/xfail markers, no new `# type: ignore`, `# noqa` or
140
+ `# pragma: no cover`. A violation is a failed check with the reason, so a retry sees it.
141
+ - `allowed_paths`: files changed outside these paths are put back and the revert is committed.
142
+ - `symbol_scope=["path.py::name", ...]`: only those top-level functions or classes may change in those
143
+ files. `integrate` then merges several jobs' edits to one Python file symbol by symbol; overlaps,
144
+ import or constant changes, and symbols changed on the target meanwhile are a conflict, never a guess.
145
+ - `oracle_cmd`: after a passing `verify`, runs the command on the job's base and on its result at the same
146
+ time and reports whether exit code and stdout match, plus `duration_ms_before`, `duration_ms_after`,
147
+ `speedup_pct`.
148
+ - `tier`: `fast` (effort low), `deep` (effort high, kind hard), `free_only`. Past 75% of the monthly cap,
149
+ `auto` and `balanced` go to free engines and the reply carries `budget_warning`.
150
+
151
+ Memory: when a check fails and a retry fixes it, subtask stores a short lesson for that repository in
152
+ `$SUBTASK_HOME/engrams/` (at most 50, least recently used dropped). Jobs on the same paths get the top three.
153
+ Groups have a blackboard file for shared notes; new notes show in `wait(since=...)`.
154
+
155
+ CLI only: `subtask board [--json]` (the view the OpenCode plugin renders), `subtask doctor`, `gc [--apply]`, `du`, `roi`, `routing explain|calibrate`,
123
156
  `watch ID...`, `install`, `uninstall`, and `--model`, `--no-sandbox`, `--untrusted` on start.
124
157
 
125
158
  ## Progress in the chat
@@ -80,15 +80,48 @@ redaction is a second layer, not a guarantee. See [SECURITY.md](SECURITY.md).
80
80
 
81
81
  | tool | what it does |
82
82
  |---|---|
83
- | `start(task, kind, repo, cwd, files, readonly, engine, max_usd, effort, base, max_turns, policy, verify)` | start one job |
84
- | `start_many(tasks)` | start several; partial success is listed per item |
85
- | `wait(ids, timeout=300, any=False, since=None)` | block until they end; progress notifications while waiting; `since` cursor for clients without progress |
83
+ | `start(task, kind, repo, cwd, files, readonly, engine, max_usd, effort, base, max_turns, policy, verify, isolation, max_retries, adversarial, context_refs, auto_repomap, tier, invariants, allowed_paths, symbol_scope, oracle_cmd)` | start one job (see below) |
84
+ | `start_many(tasks)` | start several; partial success is listed per item. Items with `id` and `depends_on` form a group (`grp-…`) run as a DAG without extra turns; `foreach=<parent id>` makes one copy per item of the parent's `.subtask/fanout.json` (at most 8), and dependents wait for every copy; `after="any"` starts when one parent succeeded |
85
+ | `integrate(ids, action="apply", auto_resolve=False)` | worktree jobs back: `diff`, `apply` (all or nothing, uncommitted), `discard`; `auto_resolve` starts a reconciliation job on conflict |
86
+ | `consensus(prompt, engines, verify_cmd, isolation="worktree")` | 2 to 4 engines solve it blind in their own worktrees, then one job compares their diffs as anonymous patches and writes one solution, checked by `verify_cmd` and the invariants; returns a group id |
87
+ | `bisect(good_ref, bad_ref, test_cmd, parallelism=4, auto_fix=False)` | first commit that makes `test_cmd` fail, testing up to `parallelism` (max 8) commits per round in temporary worktrees; returns the commit, author and diff; `auto_fix` starts a verified fix job |
88
+ | `map_reduce(glob_pattern, prompt_template, batch_size=5, concurrency=3, verify_cmd, isolation="worktree")` | one job per batch of files (`{{files}}` in the template), at most `concurrency` at a time; returns a group id |
89
+ | `wait(ids, timeout=300, mode="all", since=None, stall_timeout=None)` | block until they end (`any`: the first; `first_success`: the first done and verified, the others are stopped); returns early on a stalled job; progress notifications while waiting; `since` cursor for clients without progress |
90
+ | `steer(id, action, instruction=None)` | `hint` (queued, sent as a reply when the attempt ends), `abort`, `retry` (fresh job with the instruction) |
91
+ | `inspect(id, tail=80, grep=None)` | raw log lines, only when the summary is not enough |
86
92
  | `status(id)` / `result(id, full, evidence)` | one line / summary or full text or JSON envelope |
87
93
  | `reply(id, message)` / `stop(id)` / `cleanup(id)` | continue / stop the work / remove a clean worktree (branch kept) |
88
94
  | `engines()` | capacity per engine with source, freshness and confidence, plus spend |
89
95
  | `step(title, path, how, decisions)` | an independent cheap model narrates the step from the repo's real state |
90
96
 
91
- CLI only: `subtask doctor`, `gc [--apply]`, `du`, `roi`, `routing explain|calibrate`,
97
+ Options of `start`:
98
+
99
+ - `isolation="worktree"`: own worktree and branch (like `repo=`), plus `SUBTASK_PORT` (from 18100),
100
+ `SUBTASK_ID` and a private `TMPDIR`. The result lists `files_modified` and `diff_stat`.
101
+ - `verify` + `max_retries`: a failed check resumes the job with the output; when every retry fails, a
102
+ worktree goes back to its best attempt (scored from "N passed / N failed").
103
+ - `adversarial=True`: a Breaker job writes edge-case tests from the spec only; they are copied into the
104
+ Builder's worktree and run with `verify`.
105
+ - `context_refs`: `file:PATH[:A-B]`, `git:diff[:REF]`, `subtask:ID:summary|diff`, resolved before start.
106
+ `auto_repomap=True` adds tracked files with their top-level symbols (cached per HEAD).
107
+ - `invariants` (default `["no_test_deletion", "no_suppression"]`, `[]` turns them off): before `verify` counts,
108
+ test functions may not decrease, no new skip/xfail markers, no new `# type: ignore`, `# noqa` or
109
+ `# pragma: no cover`. A violation is a failed check with the reason, so a retry sees it.
110
+ - `allowed_paths`: files changed outside these paths are put back and the revert is committed.
111
+ - `symbol_scope=["path.py::name", ...]`: only those top-level functions or classes may change in those
112
+ files. `integrate` then merges several jobs' edits to one Python file symbol by symbol; overlaps,
113
+ import or constant changes, and symbols changed on the target meanwhile are a conflict, never a guess.
114
+ - `oracle_cmd`: after a passing `verify`, runs the command on the job's base and on its result at the same
115
+ time and reports whether exit code and stdout match, plus `duration_ms_before`, `duration_ms_after`,
116
+ `speedup_pct`.
117
+ - `tier`: `fast` (effort low), `deep` (effort high, kind hard), `free_only`. Past 75% of the monthly cap,
118
+ `auto` and `balanced` go to free engines and the reply carries `budget_warning`.
119
+
120
+ Memory: when a check fails and a retry fixes it, subtask stores a short lesson for that repository in
121
+ `$SUBTASK_HOME/engrams/` (at most 50, least recently used dropped). Jobs on the same paths get the top three.
122
+ Groups have a blackboard file for shared notes; new notes show in `wait(since=...)`.
123
+
124
+ CLI only: `subtask board [--json]` (the view the OpenCode plugin renders), `subtask doctor`, `gc [--apply]`, `du`, `roi`, `routing explain|calibrate`,
92
125
  `watch ID...`, `install`, `uninstall`, and `--model`, `--no-sandbox`, `--untrusted` on start.
93
126
 
94
127
  ## Progress in the chat
@@ -1,3 +1,3 @@
1
1
  """subtask: background sub-agents for coding agents."""
2
2
 
3
- __version__ = "0.2.11"
3
+ __version__ = "0.4.4"
@@ -0,0 +1,124 @@
1
+ """Merge Python files by top-level symbol instead of by line.
2
+
3
+ Each job's change to a file is read as a set of replaced, added or removed
4
+ top-level functions and classes. When the jobs' sets do not overlap, and none
5
+ of them changes module-level statements (imports, constants), the symbols are
6
+ grafted onto the base text one by one. Anything else is a conflict: nothing is
7
+ guessed.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import ast
13
+ import re
14
+
15
+ MODULE = "<module>"
16
+ DEFS = (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)
17
+
18
+
19
+ class Conflict(Exception):
20
+ pass
21
+
22
+
23
+ def _lines(text: str) -> list[str]:
24
+ """Lines as ast counts them: only \\n ends a line (splitlines also splits on form feeds)."""
25
+ if re.search(r"\r(?!\n)", text):
26
+ raise SyntaxError("lone carriage return")
27
+ return re.findall(r"[^\n]*\n|[^\n]+$", text)
28
+
29
+
30
+ def _main_guard(tree: ast.Module) -> int | None:
31
+ """First line of a top-level `if __name__ == "__main__":`, where new symbols must go before."""
32
+ for node in tree.body:
33
+ test = getattr(node, "test", None)
34
+ is_compare = isinstance(node, ast.If) and isinstance(test, ast.Compare)
35
+ if is_compare and isinstance(test.left, ast.Name) and test.left.id == "__name__":
36
+ return node.lineno
37
+ return None
38
+
39
+
40
+ def _parts(text: str) -> tuple[dict[str, tuple[int, int, str]], list[str]]:
41
+ """{name: (first line, last line, source)} for top-level defs, and everything else.
42
+
43
+ A second def with the same name is keyed name#2. Everything outside the defs (statements,
44
+ comments) is kept as non-blank text, so a change there is a module-level change.
45
+ """
46
+ tree = ast.parse(text)
47
+ lines = _lines(text)
48
+ defs: dict[str, tuple[int, int, str]] = {}
49
+ taken = [False] * (len(lines) + 1)
50
+ for node in tree.body:
51
+ if not isinstance(node, DEFS):
52
+ continue
53
+ start = min([node.lineno] + [d.lineno for d in node.decorator_list])
54
+ end = node.end_lineno or node.lineno
55
+ name, n = node.name, 1
56
+ while name in defs:
57
+ n += 1
58
+ name = f"{node.name}#{n}"
59
+ defs[name] = (start, end, "".join(lines[start - 1 : end]))
60
+ for i in range(start, end + 1):
61
+ taken[i] = True
62
+ other = [line.strip() for i, line in enumerate(lines, 1) if not taken[i] and line.strip()]
63
+ return defs, other
64
+
65
+
66
+ def changed_symbols(old: str, new: str) -> set[str]:
67
+ """Top-level names whose source differs; MODULE when other statements differ. Raises SyntaxError."""
68
+ old_defs, old_other = _parts(old) if old else ({}, [])
69
+ new_defs, new_other = _parts(new) if new else ({}, [])
70
+ names = {
71
+ n
72
+ for n in old_defs.keys() | new_defs.keys()
73
+ if (old_defs.get(n) or (0, 0, ""))[2] != (new_defs.get(n) or (0, 0, ""))[2]
74
+ }
75
+ if old_other != new_other:
76
+ names.add(MODULE)
77
+ return names
78
+
79
+
80
+ def graft(base: str, versions: list[tuple[str, str, str]]) -> str:
81
+ """Apply each (job id, its base text, its new text) symbol by symbol onto base. Raises Conflict."""
82
+ try:
83
+ base_defs, _ = _parts(base)
84
+ except SyntaxError as exc:
85
+ raise Conflict(f"base is not valid Python ({exc.msg})") from exc
86
+ owner: dict[str, str] = {}
87
+ edits: list[tuple[str, str | None]] = [] # (name, new source or None for removed)
88
+ for job_id, old, text in versions:
89
+ try:
90
+ new_defs, _ = _parts(text)
91
+ old_defs, _ = _parts(old) if old else ({}, [])
92
+ names = changed_symbols(old, text)
93
+ except SyntaxError as exc:
94
+ raise Conflict(f"{job_id}: not valid Python ({exc.msg})") from exc
95
+ if MODULE in names:
96
+ raise Conflict(f"{job_id} changes module-level statements (imports or constants)")
97
+ for name in names:
98
+ if name in owner:
99
+ raise Conflict(f"{owner[name]} and {job_id} both change {name}")
100
+ owner[name] = job_id
101
+ if (base_defs.get(name) or (0, 0, ""))[2] != (old_defs.get(name) or (0, 0, ""))[2]:
102
+ raise Conflict(f"{name} changed on the target too since {job_id} started")
103
+ edits.append((name, new_defs[name][2] if name in new_defs else None))
104
+ lines = _lines(base)
105
+ added = "".join(
106
+ source.rstrip("\n") + "\n\n\n"
107
+ for _, source in sorted(e for e in edits if e[0] not in base_defs)
108
+ if source
109
+ )
110
+ guard = _main_guard(ast.parse(base))
111
+ # (first line, last line, new text); applied bottom-up so earlier line numbers stay valid
112
+ ops = [(base_defs[n][0], base_defs[n][1], src or "") for n, src in edits if n in base_defs]
113
+ if added and guard is not None: # before the main block, which may call them
114
+ ops.append((guard, guard - 1, added))
115
+ for start, end, src in sorted(ops, key=lambda o: (-o[0], -o[1])):
116
+ lines[start - 1 : end] = [src] if src else []
117
+ text = "".join(lines)
118
+ if added and guard is None:
119
+ text = text.rstrip("\n") + "\n\n\n" + added.rstrip("\n") + "\n"
120
+ try:
121
+ ast.parse(text)
122
+ except SyntaxError as exc:
123
+ raise Conflict(f"merged file is not valid Python ({exc.msg})") from exc
124
+ return text
@@ -0,0 +1,141 @@
1
+ """K-ary parallel bisect: test up to K commits at once to find the first one that breaks a check.
2
+
3
+ The commits between good (passes) and bad (fails) are taken in first-parent
4
+ order. Each round tests K evenly spaced commits in temporary worktrees, at the
5
+ same time, and keeps the interval between the last pass and the first failure.
6
+ Rounds needed: about log_(K+1) of the commit count. Worktrees are always removed.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import secrets
12
+ import shutil
13
+ import subprocess
14
+ import time
15
+ from concurrent.futures import ThreadPoolExecutor
16
+
17
+ from . import config, jobs
18
+
19
+ TIMEOUT_S = 900
20
+ MAX_COMMITS = 5000
21
+ MAX_DIFF = 12000
22
+
23
+
24
+ def _git(repo: str, *args: str) -> subprocess.CompletedProcess:
25
+ return subprocess.run(
26
+ ["git", "-C", repo, *args], capture_output=True, text=True, timeout=120, check=False
27
+ )
28
+
29
+
30
+ def _passes(repo: str, commit: str, cmd: str) -> bool:
31
+ scratch = str(config.HOME / "bisect" / secrets.token_hex(4))
32
+ try:
33
+ if _git(repo, "worktree", "add", "-q", "--detach", scratch, commit).returncode != 0:
34
+ raise RuntimeError(f"worktree for {commit[:9]} not created")
35
+ try:
36
+ done = subprocess.run(
37
+ ["sh", "-c", cmd], cwd=scratch, capture_output=True, timeout=TIMEOUT_S, check=False,
38
+ stdin=subprocess.DEVNULL,
39
+ ) # fmt: skip
40
+ return done.returncode == 0
41
+ except subprocess.TimeoutExpired:
42
+ return False
43
+ finally:
44
+ _git(repo, "worktree", "remove", "--force", scratch)
45
+ shutil.rmtree(scratch, ignore_errors=True)
46
+
47
+
48
+ def _note(run: str, **fields) -> None:
49
+ """Progress a client panel can show while the bisect runs (subtask board)."""
50
+ path = config.HOME / "bisect-runs" / f"{run}.json"
51
+ path.parent.mkdir(parents=True, exist_ok=True)
52
+ if not path.exists(): # keep the folder small: the 20 newest runs
53
+ for stale in sorted(path.parent.glob("*.json"), key=lambda p: p.stat().st_mtime)[:-19]:
54
+ stale.unlink(missing_ok=True)
55
+ old = jobs.read_json(path) if path.is_file() else {}
56
+ jobs.write_json(path, {**old, **fields, "updated": time.time()})
57
+
58
+
59
+ def recent(limit: int = 3, max_age_s: float = 86400) -> list[dict]:
60
+ folder = config.HOME / "bisect-runs"
61
+ if not folder.is_dir():
62
+ return []
63
+ rows = [
64
+ jobs.read_json(p)
65
+ for p in sorted(folder.glob("*.json"), key=lambda p: p.stat().st_mtime, reverse=True)
66
+ ]
67
+ return [r for r in rows if time.time() - float(r.get("updated") or 0) < max_age_s][:limit]
68
+
69
+
70
+ def find(repo: str, good: str, bad: str, cmd: str, parallelism: int = 4) -> dict:
71
+ """{'commit', 'rounds', 'tested'} or {'error'}."""
72
+ resolved = []
73
+ for ref in (good, bad):
74
+ if ref.startswith("-"):
75
+ return {"error": f"invalid ref: {ref}"}
76
+ done = _git(repo, "rev-parse", "--verify", "--end-of-options", ref + "^{commit}")
77
+ if done.returncode != 0:
78
+ return {"error": f"unknown ref: {ref}"}
79
+ resolved.append(done.stdout.strip())
80
+ good_sha, bad_sha = resolved
81
+ if _git(repo, "merge-base", "--is-ancestor", good_sha, bad_sha).returncode != 0:
82
+ return {"error": "good_ref is not an ancestor of bad_ref"}
83
+ commits = _git(repo, "rev-list", "--first-parent", "--reverse", f"{good_sha}..{bad_sha}").stdout.split()
84
+ if not commits or len(commits) > MAX_COMMITS:
85
+ return {"error": f"{len(commits)} commits between good and bad (1 to {MAX_COMMITS} allowed)"}
86
+ k = max(1, min(int(parallelism), 8))
87
+ run = "bis-" + secrets.token_hex(4)
88
+ _note(run, id=run, test_cmd=cmd, candidates=len(commits), round=0, tested=0, state="running")
89
+ try:
90
+ return _search(repo, commits, cmd, k, run)
91
+ except (RuntimeError, OSError, subprocess.SubprocessError) as exc:
92
+ _note(run, state="failed", error=str(exc)[:200])
93
+ return {"error": f"bisect stopped: {exc}"}
94
+
95
+
96
+ def _search(repo: str, commits: list[str], cmd: str, k: int, run: str) -> dict:
97
+ lo, hi = -1, len(commits) - 1 # commits[lo] passes (-1 = good), commits[hi] fails (bad)
98
+ rounds, tested = 0, 0
99
+ with ThreadPoolExecutor(k) as pool:
100
+ confirmed = False # bad_ref itself is tested in the first round
101
+ while hi - lo > 1 or not confirmed:
102
+ span = hi - lo - 1
103
+ picks = sorted({lo + 1 + (span * (i + 1)) // (k + 1) for i in range(min(k, span))})
104
+ if not confirmed:
105
+ picks = sorted(set(picks) | {hi})
106
+ results = dict(zip(picks, pool.map(lambda i: _passes(repo, commits[i], cmd), picks)))
107
+ if not confirmed:
108
+ confirmed = True
109
+ if results[hi]:
110
+ _note(run, state="failed", error="test_cmd passes on bad_ref")
111
+ return {"error": "test_cmd passes on bad_ref: nothing to find"}
112
+ rounds, tested = rounds + 1, tested + len(picks)
113
+ _note(run, round=rounds, tested=tested, left=max(hi - lo - 1, 0))
114
+ for i in picks: # the interval ends at the first failing pick
115
+ if results[i]:
116
+ lo = i
117
+ else:
118
+ hi = i
119
+ break
120
+ _note(run, state="done", commit=commits[hi], round=rounds, tested=tested, left=0)
121
+ return {"commit": commits[hi], "rounds": rounds, "tested": tested, "candidates": len(commits)}
122
+
123
+
124
+ def report(repo: str, found: dict) -> str:
125
+ if found.get("error"):
126
+ return found["error"]
127
+ sha = found["commit"]
128
+ meta = _git(repo, "show", "-s", "--format=%H%n%an <%ae>%n%ad%n%s", sha).stdout.strip()
129
+ diff = _git(repo, "show", "--stat", "--patch", "--format=", sha).stdout
130
+ diff = diff[:MAX_DIFF] + ("\n… diff cut" if len(diff) > MAX_DIFF else "")
131
+ head = (
132
+ f"first bad commit: {meta}\n"
133
+ f"{found['candidates']} candidates, {found['tested']} tested in {found['rounds']} parallel rounds"
134
+ )
135
+ return head + "\n\n" + diff
136
+
137
+
138
+ FIX_TASK = (
139
+ "Commit {sha} made `{cmd}` fail. Fix the regression without undoing what that commit was for. "
140
+ "Run the check, commit, and finish with a ## Summary.\n\nThe commit:\n{diff}"
141
+ )
@@ -3,6 +3,8 @@
3
3
  subtask start "brief" [--engine auto] [--kind code] [--repo PATH] [--readonly] [--max-usd 2]
4
4
  subtask wait ID [ID...] [--timeout 50]
5
5
  subtask status [ID] | result ID [--full|--evidence] | reply ID "msg" | stop ID | cleanup ID | spend | engines
6
+ subtask steer ID hint|abort|retry ["text"] | inspect ID [--tail N] [--grep W] | board [--json]
7
+ subtask integrate ID... [--action diff|apply|discard] [--auto-resolve] | map-reduce GLOB "tpl {{files}}"
6
8
  subtask install|uninstall --client claude|codex|kimi|opencode|all [--yes]
7
9
  subtask doctor [--json] [--client X] | gc [--days 30] [--apply] | du | roi | routing explain|calibrate | watch ID...
8
10
 
@@ -48,6 +50,7 @@ def main() -> int:
48
50
  w.add_argument("--no-results", action="store_true")
49
51
  w.add_argument("--any", action="store_true", help="return when the first job ends")
50
52
  w.add_argument("--since", help="cursor from a previous wait ('' to start): adds what changed")
53
+ w.add_argument("--mode", choices=["all", "any", "first_success"])
51
54
  st = sub.add_parser("status")
52
55
  st.add_argument("id", nargs="?")
53
56
  r = sub.add_parser("result")
@@ -81,6 +84,30 @@ def main() -> int:
81
84
  ro.add_argument("--kind", default="code", choices=["read", "code", "review", "hard"])
82
85
  ro.add_argument("--policy", choices=["worktree", "sandbox", "isolated", "untrusted"])
83
86
  ro.add_argument("--readonly", action="store_true")
87
+ bo = sub.add_parser("board", help="groups, jobs, verify and budget in one view (the OpenCode panel)")
88
+ bo.add_argument("--json", action="store_true")
89
+ bo.add_argument("--limit", type=int, default=10)
90
+ se = sub.add_parser("steer")
91
+ se.add_argument("id")
92
+ se.add_argument("action", choices=["hint", "abort", "retry"])
93
+ se.add_argument("instruction", nargs="?")
94
+ ins = sub.add_parser("inspect")
95
+ ins.add_argument("id")
96
+ ins.add_argument("--tail", type=int, default=80)
97
+ ins.add_argument("--grep")
98
+ ig = sub.add_parser("integrate")
99
+ ig.add_argument("ids", nargs="+")
100
+ ig.add_argument("--action", default="apply", choices=["diff", "apply", "discard"])
101
+ ig.add_argument("--auto-resolve", action="store_true")
102
+ mr = sub.add_parser("map-reduce")
103
+ mr.add_argument("glob")
104
+ mr.add_argument("template", help="prompt with {{files}}")
105
+ mr.add_argument("--batch-size", type=int, default=5)
106
+ mr.add_argument("--concurrency", type=int, default=3)
107
+ mr.add_argument("--verify")
108
+ mr.add_argument("--isolation", default="worktree", choices=["shared", "worktree"])
109
+ mr.add_argument("--repo")
110
+ mr.add_argument("--engine", default="auto")
84
111
  wa = sub.add_parser("watch", help="terminal view of job progress (diagnostic, not the chat)")
85
112
  wa.add_argument("ids", nargs="+")
86
113
  wa.add_argument("--interval", type=float, default=2.0)
@@ -90,6 +117,9 @@ def main() -> int:
90
117
  from . import stephook
91
118
 
92
119
  return stephook.main()
120
+ if a.cmd == "install-skill":
121
+ print("\n".join(install_commands.install_skill(a.dest, a.client)))
122
+ return 0
93
123
  if a.cmd in ("install", "uninstall") or (a.cmd == "doctor" and a.client):
94
124
  return install_commands.run(a)
95
125
 
@@ -114,7 +144,7 @@ def main() -> int:
114
144
  untrusted=a.untrusted,
115
145
  )
116
146
  elif a.cmd == "wait":
117
- out = core.wait(a.ids, a.timeout, a.any, not a.no_results, since=a.since)
147
+ out = core.wait(a.ids, a.timeout, a.any, not a.no_results, since=a.since, mode=a.mode)
118
148
  elif a.cmd == "status":
119
149
  out = core.status(a.id)
120
150
  elif a.cmd == "result":
@@ -152,6 +182,23 @@ def main() -> int:
152
182
 
153
183
  wanted = policy.demand(a.readonly, a.policy)
154
184
  out = router.explain(a.kind, None, wanted) if a.action == "explain" else router.calibrate()
185
+ elif a.cmd == "board":
186
+ import json
187
+
188
+ out = json.dumps(core.board_data(a.limit)) if a.json else core.board(a.limit)
189
+ elif a.cmd == "steer":
190
+ out = core.steer(a.id, a.action, a.instruction)
191
+ elif a.cmd == "inspect":
192
+ out = core.inspect(a.id, a.tail, a.grep)
193
+ elif a.cmd == "integrate":
194
+ from .integrate import integrate
195
+
196
+ out = integrate(a.ids, a.action, a.auto_resolve)
197
+ elif a.cmd == "map-reduce":
198
+ from .map_reduce import map_reduce
199
+
200
+ out = map_reduce(a.glob, a.template, a.batch_size, a.concurrency, a.verify, a.isolation, a.repo,
201
+ None, a.engine) # fmt: skip
155
202
  elif a.cmd == "watch":
156
203
  return _watch(a.ids, a.interval)
157
204
  else:
@@ -0,0 +1,140 @@
1
+ """Context resolved on the server before a job starts, so the worker does not spend turns finding it.
2
+
3
+ References:
4
+ file:PATH file:PATH:START-END (inside the job's folder only)
5
+ git:diff git:diff:REF
6
+ subtask:ID:summary subtask:ID:diff
7
+ Plus an optional repo map: tracked files with their top-level symbols, cached per HEAD.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import ast
13
+ import hashlib
14
+ import re
15
+ import subprocess
16
+ from pathlib import Path
17
+
18
+ from . import config
19
+
20
+ MAX_REF = 20_000
21
+ MAX_TOTAL = 60_000
22
+ MAX_MAP = 15_000
23
+ SYMBOL = {
24
+ ".ts": re.compile(
25
+ r"^(?:export\s+)?(?:async\s+)?(?:function|class|interface|type|const)\s+(\w+)", re.MULTILINE
26
+ ),
27
+ ".js": re.compile(r"^(?:export\s+)?(?:async\s+)?(?:function|class|const)\s+(\w+)", re.MULTILINE),
28
+ ".go": re.compile(r"^(?:func|type)\s+(?:\([^)]*\)\s*)?(\w+)", re.MULTILINE),
29
+ ".rs": re.compile(r"^\s*(?:pub\s+)?(?:fn|struct|enum|trait)\s+(\w+)", re.MULTILINE),
30
+ }
31
+ SYMBOL[".tsx"] = SYMBOL[".ts"]
32
+
33
+
34
+ def _git(root: str, *args: str) -> str:
35
+ done = subprocess.run(["git", "-C", root, *args], capture_output=True, text=True, timeout=30, check=False)
36
+ return done.stdout if done.returncode == 0 else ""
37
+
38
+
39
+ def _file(root: Path, ref: str) -> str:
40
+ match = re.fullmatch(r"(.+?)(?::(\d+)-(\d+))?", ref)
41
+ assert match
42
+ path = (root / match.group(1)).resolve()
43
+ if not path.is_relative_to(root.resolve()):
44
+ raise ValueError(f"outside the job folder: {match.group(1)}")
45
+ if not path.is_file():
46
+ raise ValueError(f"no such file: {match.group(1)}")
47
+ lines = path.read_text(errors="replace").splitlines()
48
+ if match.group(2):
49
+ a, b = int(match.group(2)), int(match.group(3))
50
+ lines = lines[max(a - 1, 0) : b]
51
+ return "\n".join(lines)
52
+
53
+
54
+ def _subtask(ref: str) -> str:
55
+ from . import core, jobs
56
+
57
+ job_id, _, what = ref.partition(":")
58
+ if not core.known(job_id):
59
+ raise ValueError(f"unknown job: {job_id}")
60
+ if what == "summary":
61
+ path = jobs.job_dir(job_id) / "result.md"
62
+ return core.summary_of(path.read_text()) if path.is_file() else ""
63
+ if what == "diff":
64
+ sp = jobs.spec(job_id)
65
+ if not sp.get("branch"):
66
+ raise ValueError(f"{job_id} has no branch")
67
+ return _git(sp["repo"], "diff", f"{sp['base']}..{sp['branch']}")
68
+ raise ValueError("subtask refs end in :summary or :diff")
69
+
70
+
71
+ def resolve(refs: list[str], root: str) -> str:
72
+ """The <subtask_context> block for `refs`. Raises ValueError on a bad reference."""
73
+ if not isinstance(refs, list) or not all(isinstance(r, str) for r in refs):
74
+ raise ValueError("context_refs must be a list of strings")
75
+ parts, total = [], 0
76
+ for ref in refs:
77
+ kind, _, rest = ref.partition(":")
78
+ if kind == "file":
79
+ body = _file(Path(root), rest)
80
+ elif kind == "git":
81
+ if rest != "diff" and not rest.startswith("diff:"):
82
+ raise ValueError(f"unknown git ref: {ref}")
83
+ target = rest.removeprefix("diff").removeprefix(":")
84
+ if target.startswith("-"):
85
+ raise ValueError(f"invalid git ref: {ref}")
86
+ body = _git(root, "diff", *([target] if target else []))
87
+ elif kind == "subtask":
88
+ body = _subtask(rest)
89
+ else:
90
+ raise ValueError(f"unknown reference: {ref}")
91
+ body = body[:MAX_REF] + ("\n… cut" if len(body) > MAX_REF else "")
92
+ if total + len(body) > MAX_TOTAL:
93
+ parts.append(f'<ref name="{ref}">left out: context limit reached</ref>')
94
+ continue
95
+ total += len(body)
96
+ parts.append(f'<ref name="{ref}">\n{body}\n</ref>')
97
+ return "<subtask_context>\n" + "\n".join(parts) + "\n</subtask_context>" if parts else ""
98
+
99
+
100
+ MAX_FILE = 1_000_000
101
+
102
+
103
+ def _symbols(path: Path) -> list[str]:
104
+ """Top-level names of a regular file; symlinks, FIFOs and devices are never opened."""
105
+ try:
106
+ if path.is_symlink() or not path.is_file() or path.stat().st_size > MAX_FILE:
107
+ return []
108
+ text = path.read_text(errors="replace")
109
+ except OSError:
110
+ return []
111
+ if path.suffix == ".py":
112
+ try:
113
+ tree = ast.parse(text)
114
+ except (SyntaxError, ValueError):
115
+ return []
116
+ return [
117
+ n.name for n in tree.body if isinstance(n, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef))
118
+ ]
119
+ pattern = SYMBOL.get(path.suffix)
120
+ return pattern.findall(text) if pattern else []
121
+
122
+
123
+ def repomap(root: str) -> str:
124
+ """Tracked files with their top-level symbols; cached until HEAD changes."""
125
+ head = _git(root, "rev-parse", "HEAD").strip()
126
+ if not head:
127
+ return ""
128
+ key = hashlib.sha256(f"{Path(root).resolve()}@{head}".encode()).hexdigest()[:16]
129
+ cache = config.HOME / "repomap" / f"{key}.txt"
130
+ if cache.is_file():
131
+ return cache.read_text()
132
+ lines = []
133
+ for name in _git(root, "ls-files").splitlines():
134
+ symbols = _symbols(Path(root) / name)
135
+ lines.append(f"{name}: {', '.join(symbols[:12])}" if symbols else name)
136
+ text = "\n".join(lines)
137
+ text = text[:MAX_MAP] + ("\n… map cut" if len(text) > MAX_MAP else "")
138
+ cache.parent.mkdir(parents=True, exist_ok=True)
139
+ cache.write_text(text)
140
+ return text