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.
- {subtask_mcp-0.2.11/subtask_mcp.egg-info → subtask_mcp-0.4.4}/PKG-INFO +38 -5
- {subtask_mcp-0.2.11 → subtask_mcp-0.4.4}/README.md +37 -4
- {subtask_mcp-0.2.11 → subtask_mcp-0.4.4}/subtask/__init__.py +1 -1
- subtask_mcp-0.4.4/subtask/ast_merge.py +124 -0
- subtask_mcp-0.4.4/subtask/bisect_tool.py +141 -0
- {subtask_mcp-0.2.11 → subtask_mcp-0.4.4}/subtask/cli.py +48 -1
- subtask_mcp-0.4.4/subtask/context.py +140 -0
- subtask_mcp-0.4.4/subtask/core.py +1142 -0
- {subtask_mcp-0.2.11 → subtask_mcp-0.4.4}/subtask/fastserver.py +34 -3
- subtask_mcp-0.4.4/subtask/groups.py +380 -0
- {subtask_mcp-0.2.11 → subtask_mcp-0.4.4}/subtask/install/commands.py +30 -0
- subtask_mcp-0.4.4/subtask/integrate.py +180 -0
- subtask_mcp-0.4.4/subtask/invariants.py +162 -0
- subtask_mcp-0.4.4/subtask/map_reduce.py +72 -0
- subtask_mcp-0.4.4/subtask/memory.py +145 -0
- subtask_mcp-0.4.4/subtask/oracle.py +80 -0
- {subtask_mcp-0.2.11 → subtask_mcp-0.4.4}/subtask/router.py +28 -4
- subtask_mcp-0.4.4/subtask/server.py +301 -0
- {subtask_mcp-0.2.11 → subtask_mcp-0.4.4}/subtask/skill/SKILL.md +44 -0
- {subtask_mcp-0.2.11 → subtask_mcp-0.4.4}/subtask/telemetry.py +30 -0
- subtask_mcp-0.4.4/subtask/tools.json +473 -0
- subtask_mcp-0.4.4/subtask/verifier.py +169 -0
- subtask_mcp-0.4.4/subtask/watchdog.py +56 -0
- {subtask_mcp-0.2.11 → subtask_mcp-0.4.4}/subtask/worker.py +105 -4
- {subtask_mcp-0.2.11 → subtask_mcp-0.4.4/subtask_mcp.egg-info}/PKG-INFO +38 -5
- {subtask_mcp-0.2.11 → subtask_mcp-0.4.4}/subtask_mcp.egg-info/SOURCES.txt +24 -1
- subtask_mcp-0.4.4/tests/test_board.py +105 -0
- subtask_mcp-0.4.4/tests/test_context_dag.py +139 -0
- subtask_mcp-0.4.4/tests/test_falsify_040.py +156 -0
- subtask_mcp-0.4.4/tests/test_falsify_tappe.py +260 -0
- subtask_mcp-0.4.4/tests/test_fanout_consensus.py +149 -0
- subtask_mcp-0.4.4/tests/test_guidance.py +101 -0
- subtask_mcp-0.4.4/tests/test_invariants_ast.py +215 -0
- subtask_mcp-0.4.4/tests/test_map_engrams.py +97 -0
- subtask_mcp-0.4.4/tests/test_oracle_bisect.py +121 -0
- subtask_mcp-0.4.4/tests/test_plan_gaps.py +50 -0
- {subtask_mcp-0.2.11 → subtask_mcp-0.4.4}/tests/test_protocol.py +1 -1
- {subtask_mcp-0.2.11 → subtask_mcp-0.4.4}/tests/test_schema.py +1 -1
- subtask_mcp-0.4.4/tests/test_steer_wait.py +178 -0
- {subtask_mcp-0.2.11 → subtask_mcp-0.4.4}/tests/test_subtask.py +1 -1
- subtask_mcp-0.4.4/tests/test_worktree_verify.py +158 -0
- subtask_mcp-0.2.11/subtask/core.py +0 -610
- subtask_mcp-0.2.11/subtask/server.py +0 -194
- subtask_mcp-0.2.11/subtask/tools.json +0 -237
- {subtask_mcp-0.2.11 → subtask_mcp-0.4.4}/LICENSE +0 -0
- {subtask_mcp-0.2.11 → subtask_mcp-0.4.4}/pyproject.toml +0 -0
- {subtask_mcp-0.2.11 → subtask_mcp-0.4.4}/setup.cfg +0 -0
- {subtask_mcp-0.2.11 → subtask_mcp-0.4.4}/subtask/admission.py +0 -0
- {subtask_mcp-0.2.11 → subtask_mcp-0.4.4}/subtask/capacity.py +0 -0
- {subtask_mcp-0.2.11 → subtask_mcp-0.4.4}/subtask/config.py +0 -0
- {subtask_mcp-0.2.11 → subtask_mcp-0.4.4}/subtask/doctor.py +0 -0
- {subtask_mcp-0.2.11 → subtask_mcp-0.4.4}/subtask/engines/__init__.py +0 -0
- {subtask_mcp-0.2.11 → subtask_mcp-0.4.4}/subtask/engines/base.py +0 -0
- {subtask_mcp-0.2.11 → subtask_mcp-0.4.4}/subtask/engines/claude_api.py +0 -0
- {subtask_mcp-0.2.11 → subtask_mcp-0.4.4}/subtask/engines/codex.py +0 -0
- {subtask_mcp-0.2.11 → subtask_mcp-0.4.4}/subtask/engines/demo.py +0 -0
- {subtask_mcp-0.2.11 → subtask_mcp-0.4.4}/subtask/engines/dsh.py +0 -0
- {subtask_mcp-0.2.11 → subtask_mcp-0.4.4}/subtask/engines/kimi.py +0 -0
- {subtask_mcp-0.2.11 → subtask_mcp-0.4.4}/subtask/engines/opencode.py +0 -0
- {subtask_mcp-0.2.11 → subtask_mcp-0.4.4}/subtask/engines/registry.py +0 -0
- {subtask_mcp-0.2.11 → subtask_mcp-0.4.4}/subtask/events.py +0 -0
- {subtask_mcp-0.2.11 → subtask_mcp-0.4.4}/subtask/evidence.py +0 -0
- {subtask_mcp-0.2.11 → subtask_mcp-0.4.4}/subtask/gc.py +0 -0
- {subtask_mcp-0.2.11 → subtask_mcp-0.4.4}/subtask/install/__init__.py +0 -0
- {subtask_mcp-0.2.11 → subtask_mcp-0.4.4}/subtask/install/claude_plugin.py +0 -0
- {subtask_mcp-0.2.11 → subtask_mcp-0.4.4}/subtask/install/clients.py +0 -0
- {subtask_mcp-0.2.11 → subtask_mcp-0.4.4}/subtask/install/doctor.py +0 -0
- {subtask_mcp-0.2.11 → subtask_mcp-0.4.4}/subtask/install/fileops.py +0 -0
- {subtask_mcp-0.2.11 → subtask_mcp-0.4.4}/subtask/install/installer.py +0 -0
- {subtask_mcp-0.2.11 → subtask_mcp-0.4.4}/subtask/install/ownership.py +0 -0
- {subtask_mcp-0.2.11 → subtask_mcp-0.4.4}/subtask/install/tomledit.py +0 -0
- {subtask_mcp-0.2.11 → subtask_mcp-0.4.4}/subtask/jobs.py +0 -0
- {subtask_mcp-0.2.11 → subtask_mcp-0.4.4}/subtask/ledger.py +0 -0
- {subtask_mcp-0.2.11 → subtask_mcp-0.4.4}/subtask/narrate.py +0 -0
- {subtask_mcp-0.2.11 → subtask_mcp-0.4.4}/subtask/policy.py +0 -0
- {subtask_mcp-0.2.11 → subtask_mcp-0.4.4}/subtask/procid.py +0 -0
- {subtask_mcp-0.2.11 → subtask_mcp-0.4.4}/subtask/progress.py +0 -0
- {subtask_mcp-0.2.11 → subtask_mcp-0.4.4}/subtask/redact.py +0 -0
- {subtask_mcp-0.2.11 → subtask_mcp-0.4.4}/subtask/roi.py +0 -0
- {subtask_mcp-0.2.11 → subtask_mcp-0.4.4}/subtask/runners.py +0 -0
- {subtask_mcp-0.2.11 → subtask_mcp-0.4.4}/subtask/schemacheck.py +0 -0
- {subtask_mcp-0.2.11 → subtask_mcp-0.4.4}/subtask/stephook.py +0 -0
- {subtask_mcp-0.2.11 → subtask_mcp-0.4.4}/subtask_mcp.egg-info/dependency_links.txt +0 -0
- {subtask_mcp-0.2.11 → subtask_mcp-0.4.4}/subtask_mcp.egg-info/entry_points.txt +0 -0
- {subtask_mcp-0.2.11 → subtask_mcp-0.4.4}/subtask_mcp.egg-info/requires.txt +0 -0
- {subtask_mcp-0.2.11 → subtask_mcp-0.4.4}/subtask_mcp.egg-info/top_level.txt +0 -0
- {subtask_mcp-0.2.11 → subtask_mcp-0.4.4}/tests/test_concurrency.py +0 -0
- {subtask_mcp-0.2.11 → subtask_mcp-0.4.4}/tests/test_engines.py +0 -0
- {subtask_mcp-0.2.11 → subtask_mcp-0.4.4}/tests/test_falsify.py +0 -0
- {subtask_mcp-0.2.11 → subtask_mcp-0.4.4}/tests/test_independent_client.py +0 -0
- {subtask_mcp-0.2.11 → subtask_mcp-0.4.4}/tests/test_install.py +0 -0
- {subtask_mcp-0.2.11 → subtask_mcp-0.4.4}/tests/test_live.py +0 -0
- {subtask_mcp-0.2.11 → subtask_mcp-0.4.4}/tests/test_routing_results.py +0 -0
- {subtask_mcp-0.2.11 → subtask_mcp-0.4.4}/tests/test_security.py +0 -0
- {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.
|
|
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
|
-
| `
|
|
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
|
-
|
|
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
|
-
| `
|
|
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
|
-
|
|
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
|
|
@@ -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
|