subtask-mcp 0.3.1__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 (92) hide show
  1. {subtask_mcp-0.3.1/subtask_mcp.egg-info → subtask_mcp-0.4.4}/PKG-INFO +15 -3
  2. {subtask_mcp-0.3.1 → subtask_mcp-0.4.4}/README.md +14 -2
  3. {subtask_mcp-0.3.1 → 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.3.1 → subtask_mcp-0.4.4}/subtask/cli.py +3 -0
  7. {subtask_mcp-0.3.1 → subtask_mcp-0.4.4}/subtask/core.py +148 -5
  8. {subtask_mcp-0.3.1 → subtask_mcp-0.4.4}/subtask/fastserver.py +11 -1
  9. {subtask_mcp-0.3.1 → subtask_mcp-0.4.4}/subtask/groups.py +69 -3
  10. {subtask_mcp-0.3.1 → subtask_mcp-0.4.4}/subtask/install/commands.py +30 -0
  11. {subtask_mcp-0.3.1 → subtask_mcp-0.4.4}/subtask/integrate.py +51 -3
  12. subtask_mcp-0.4.4/subtask/invariants.py +162 -0
  13. subtask_mcp-0.4.4/subtask/oracle.py +80 -0
  14. {subtask_mcp-0.3.1 → subtask_mcp-0.4.4}/subtask/server.py +69 -12
  15. {subtask_mcp-0.3.1 → subtask_mcp-0.4.4}/subtask/skill/SKILL.md +44 -0
  16. {subtask_mcp-0.3.1 → subtask_mcp-0.4.4}/subtask/tools.json +98 -8
  17. {subtask_mcp-0.3.1 → subtask_mcp-0.4.4}/subtask/worker.py +47 -1
  18. {subtask_mcp-0.3.1 → subtask_mcp-0.4.4/subtask_mcp.egg-info}/PKG-INFO +15 -3
  19. {subtask_mcp-0.3.1 → subtask_mcp-0.4.4}/subtask_mcp.egg-info/SOURCES.txt +9 -0
  20. {subtask_mcp-0.3.1 → subtask_mcp-0.4.4}/tests/test_board.py +39 -0
  21. subtask_mcp-0.4.4/tests/test_falsify_040.py +156 -0
  22. {subtask_mcp-0.3.1 → subtask_mcp-0.4.4}/tests/test_falsify_tappe.py +8 -3
  23. subtask_mcp-0.4.4/tests/test_fanout_consensus.py +149 -0
  24. subtask_mcp-0.4.4/tests/test_guidance.py +101 -0
  25. subtask_mcp-0.4.4/tests/test_invariants_ast.py +215 -0
  26. subtask_mcp-0.4.4/tests/test_oracle_bisect.py +121 -0
  27. {subtask_mcp-0.3.1 → subtask_mcp-0.4.4}/tests/test_protocol.py +1 -1
  28. {subtask_mcp-0.3.1 → subtask_mcp-0.4.4}/tests/test_schema.py +1 -1
  29. {subtask_mcp-0.3.1 → subtask_mcp-0.4.4}/tests/test_subtask.py +1 -1
  30. {subtask_mcp-0.3.1 → subtask_mcp-0.4.4}/LICENSE +0 -0
  31. {subtask_mcp-0.3.1 → subtask_mcp-0.4.4}/pyproject.toml +0 -0
  32. {subtask_mcp-0.3.1 → subtask_mcp-0.4.4}/setup.cfg +0 -0
  33. {subtask_mcp-0.3.1 → subtask_mcp-0.4.4}/subtask/admission.py +0 -0
  34. {subtask_mcp-0.3.1 → subtask_mcp-0.4.4}/subtask/capacity.py +0 -0
  35. {subtask_mcp-0.3.1 → subtask_mcp-0.4.4}/subtask/config.py +0 -0
  36. {subtask_mcp-0.3.1 → subtask_mcp-0.4.4}/subtask/context.py +0 -0
  37. {subtask_mcp-0.3.1 → subtask_mcp-0.4.4}/subtask/doctor.py +0 -0
  38. {subtask_mcp-0.3.1 → subtask_mcp-0.4.4}/subtask/engines/__init__.py +0 -0
  39. {subtask_mcp-0.3.1 → subtask_mcp-0.4.4}/subtask/engines/base.py +0 -0
  40. {subtask_mcp-0.3.1 → subtask_mcp-0.4.4}/subtask/engines/claude_api.py +0 -0
  41. {subtask_mcp-0.3.1 → subtask_mcp-0.4.4}/subtask/engines/codex.py +0 -0
  42. {subtask_mcp-0.3.1 → subtask_mcp-0.4.4}/subtask/engines/demo.py +0 -0
  43. {subtask_mcp-0.3.1 → subtask_mcp-0.4.4}/subtask/engines/dsh.py +0 -0
  44. {subtask_mcp-0.3.1 → subtask_mcp-0.4.4}/subtask/engines/kimi.py +0 -0
  45. {subtask_mcp-0.3.1 → subtask_mcp-0.4.4}/subtask/engines/opencode.py +0 -0
  46. {subtask_mcp-0.3.1 → subtask_mcp-0.4.4}/subtask/engines/registry.py +0 -0
  47. {subtask_mcp-0.3.1 → subtask_mcp-0.4.4}/subtask/events.py +0 -0
  48. {subtask_mcp-0.3.1 → subtask_mcp-0.4.4}/subtask/evidence.py +0 -0
  49. {subtask_mcp-0.3.1 → subtask_mcp-0.4.4}/subtask/gc.py +0 -0
  50. {subtask_mcp-0.3.1 → subtask_mcp-0.4.4}/subtask/install/__init__.py +0 -0
  51. {subtask_mcp-0.3.1 → subtask_mcp-0.4.4}/subtask/install/claude_plugin.py +0 -0
  52. {subtask_mcp-0.3.1 → subtask_mcp-0.4.4}/subtask/install/clients.py +0 -0
  53. {subtask_mcp-0.3.1 → subtask_mcp-0.4.4}/subtask/install/doctor.py +0 -0
  54. {subtask_mcp-0.3.1 → subtask_mcp-0.4.4}/subtask/install/fileops.py +0 -0
  55. {subtask_mcp-0.3.1 → subtask_mcp-0.4.4}/subtask/install/installer.py +0 -0
  56. {subtask_mcp-0.3.1 → subtask_mcp-0.4.4}/subtask/install/ownership.py +0 -0
  57. {subtask_mcp-0.3.1 → subtask_mcp-0.4.4}/subtask/install/tomledit.py +0 -0
  58. {subtask_mcp-0.3.1 → subtask_mcp-0.4.4}/subtask/jobs.py +0 -0
  59. {subtask_mcp-0.3.1 → subtask_mcp-0.4.4}/subtask/ledger.py +0 -0
  60. {subtask_mcp-0.3.1 → subtask_mcp-0.4.4}/subtask/map_reduce.py +0 -0
  61. {subtask_mcp-0.3.1 → subtask_mcp-0.4.4}/subtask/memory.py +0 -0
  62. {subtask_mcp-0.3.1 → subtask_mcp-0.4.4}/subtask/narrate.py +0 -0
  63. {subtask_mcp-0.3.1 → subtask_mcp-0.4.4}/subtask/policy.py +0 -0
  64. {subtask_mcp-0.3.1 → subtask_mcp-0.4.4}/subtask/procid.py +0 -0
  65. {subtask_mcp-0.3.1 → subtask_mcp-0.4.4}/subtask/progress.py +0 -0
  66. {subtask_mcp-0.3.1 → subtask_mcp-0.4.4}/subtask/redact.py +0 -0
  67. {subtask_mcp-0.3.1 → subtask_mcp-0.4.4}/subtask/roi.py +0 -0
  68. {subtask_mcp-0.3.1 → subtask_mcp-0.4.4}/subtask/router.py +0 -0
  69. {subtask_mcp-0.3.1 → subtask_mcp-0.4.4}/subtask/runners.py +0 -0
  70. {subtask_mcp-0.3.1 → subtask_mcp-0.4.4}/subtask/schemacheck.py +0 -0
  71. {subtask_mcp-0.3.1 → subtask_mcp-0.4.4}/subtask/stephook.py +0 -0
  72. {subtask_mcp-0.3.1 → subtask_mcp-0.4.4}/subtask/telemetry.py +0 -0
  73. {subtask_mcp-0.3.1 → subtask_mcp-0.4.4}/subtask/verifier.py +0 -0
  74. {subtask_mcp-0.3.1 → subtask_mcp-0.4.4}/subtask/watchdog.py +0 -0
  75. {subtask_mcp-0.3.1 → subtask_mcp-0.4.4}/subtask_mcp.egg-info/dependency_links.txt +0 -0
  76. {subtask_mcp-0.3.1 → subtask_mcp-0.4.4}/subtask_mcp.egg-info/entry_points.txt +0 -0
  77. {subtask_mcp-0.3.1 → subtask_mcp-0.4.4}/subtask_mcp.egg-info/requires.txt +0 -0
  78. {subtask_mcp-0.3.1 → subtask_mcp-0.4.4}/subtask_mcp.egg-info/top_level.txt +0 -0
  79. {subtask_mcp-0.3.1 → subtask_mcp-0.4.4}/tests/test_concurrency.py +0 -0
  80. {subtask_mcp-0.3.1 → subtask_mcp-0.4.4}/tests/test_context_dag.py +0 -0
  81. {subtask_mcp-0.3.1 → subtask_mcp-0.4.4}/tests/test_engines.py +0 -0
  82. {subtask_mcp-0.3.1 → subtask_mcp-0.4.4}/tests/test_falsify.py +0 -0
  83. {subtask_mcp-0.3.1 → subtask_mcp-0.4.4}/tests/test_independent_client.py +0 -0
  84. {subtask_mcp-0.3.1 → subtask_mcp-0.4.4}/tests/test_install.py +0 -0
  85. {subtask_mcp-0.3.1 → subtask_mcp-0.4.4}/tests/test_live.py +0 -0
  86. {subtask_mcp-0.3.1 → subtask_mcp-0.4.4}/tests/test_map_engrams.py +0 -0
  87. {subtask_mcp-0.3.1 → subtask_mcp-0.4.4}/tests/test_plan_gaps.py +0 -0
  88. {subtask_mcp-0.3.1 → subtask_mcp-0.4.4}/tests/test_routing_results.py +0 -0
  89. {subtask_mcp-0.3.1 → subtask_mcp-0.4.4}/tests/test_security.py +0 -0
  90. {subtask_mcp-0.3.1 → subtask_mcp-0.4.4}/tests/test_steer_wait.py +0 -0
  91. {subtask_mcp-0.3.1 → subtask_mcp-0.4.4}/tests/test_stephook.py +0 -0
  92. {subtask_mcp-0.3.1 → subtask_mcp-0.4.4}/tests/test_worktree_verify.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: subtask-mcp
3
- Version: 0.3.1
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,9 +111,11 @@ 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, isolation, max_retries, adversarial, context_refs, auto_repomap, tier)` | 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 |
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
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 |
117
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 |
118
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 |
119
121
  | `steer(id, action, instruction=None)` | `hint` (queued, sent as a reply when the attempt ends), `abort`, `retry` (fresh job with the instruction) |
@@ -133,6 +135,16 @@ Options of `start`:
133
135
  Builder's worktree and run with `verify`.
134
136
  - `context_refs`: `file:PATH[:A-B]`, `git:diff[:REF]`, `subtask:ID:summary|diff`, resolved before start.
135
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`.
136
148
  - `tier`: `fast` (effort low), `deep` (effort high, kind hard), `free_only`. Past 75% of the monthly cap,
137
149
  `auto` and `balanced` go to free engines and the reply carries `budget_warning`.
138
150
 
@@ -80,9 +80,11 @@ 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, isolation, max_retries, adversarial, context_refs, auto_repomap, tier)` | 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 |
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
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 |
86
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 |
87
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 |
88
90
  | `steer(id, action, instruction=None)` | `hint` (queued, sent as a reply when the attempt ends), `abort`, `retry` (fresh job with the instruction) |
@@ -102,6 +104,16 @@ Options of `start`:
102
104
  Builder's worktree and run with `verify`.
103
105
  - `context_refs`: `file:PATH[:A-B]`, `git:diff[:REF]`, `subtask:ID:summary|diff`, resolved before start.
104
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`.
105
117
  - `tier`: `fast` (effort low), `deep` (effort high, kind hard), `free_only`. Past 75% of the monthly cap,
106
118
  `auto` and `balanced` go to free engines and the reply carries `budget_warning`.
107
119
 
@@ -1,3 +1,3 @@
1
1
  """subtask: background sub-agents for coding agents."""
2
2
 
3
- __version__ = "0.3.1"
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
+ )
@@ -117,6 +117,9 @@ def main() -> int:
117
117
  from . import stephook
118
118
 
119
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
120
123
  if a.cmd in ("install", "uninstall") or (a.cmd == "doctor" and a.client):
121
124
  return install_commands.run(a)
122
125
 
@@ -97,6 +97,10 @@ def start(
97
97
  context_refs: list[str] | None = None,
98
98
  auto_repomap: bool = False,
99
99
  tier: str = "auto",
100
+ invariants: list[str] | None = None,
101
+ allowed_paths: list[str] | None = None,
102
+ symbol_scope: list[str] | None = None,
103
+ oracle_cmd: str | None = None,
100
104
  _breaker: str | None = None,
101
105
  _group: str | None = None,
102
106
  ) -> str:
@@ -112,6 +116,17 @@ def start(
112
116
  return "max_retries needs verify"
113
117
  if tier not in TIERS:
114
118
  return "tier must be " + "|".join(TIERS)
119
+ from . import invariants as inv
120
+
121
+ problem = inv.validate(invariants, allowed_paths, symbol_scope)
122
+ if problem:
123
+ return problem
124
+ if oracle_cmd is not None and not (isinstance(oracle_cmd, str) and oracle_cmd.strip()):
125
+ return "oracle_cmd must be a non-empty command"
126
+ if oracle_cmd and not (repo or isolation == "worktree") or oracle_cmd and readonly:
127
+ return "oracle_cmd needs a worktree (repo or isolation=worktree)"
128
+ if (allowed_paths or symbol_scope) and not (repo or isolation == "worktree"):
129
+ return "allowed_paths and symbol_scope need a worktree (repo or isolation=worktree)"
115
130
  tier, budget_warning = _degrade(tier)
116
131
  if tier == "fast" and effort == "medium":
117
132
  effort = "low"
@@ -147,7 +162,9 @@ def start(
147
162
  breaker = broke.split()[0]
148
163
  built = start(
149
164
  task, engine, repo, None, base, False, max_usd, max_turns, effort, model, sandbox, kind,
150
- files, policy, verify, untrusted, progress, max_retries=max_retries, _breaker=breaker, _group=_group,
165
+ files, policy, verify, untrusted, progress, max_retries=max_retries, invariants=invariants,
166
+ allowed_paths=allowed_paths, symbol_scope=symbol_scope, oracle_cmd=oracle_cmd, _breaker=breaker,
167
+ _group=_group,
151
168
  ) # fmt: skip
152
169
  if "already running" in built: # no slot now: a group starts the Builder when one frees
153
170
  from . import groups
@@ -156,7 +173,8 @@ def start(
156
173
  "task": task, "engine": engine, "repo": repo, "base": base, "max_usd": max_usd,
157
174
  "max_turns": max_turns, "effort": effort, "model": model, "kind": kind, "files": files,
158
175
  "policy": policy, "verify": verify, "untrusted": untrusted, "max_retries": max_retries,
159
- "_breaker": breaker,
176
+ "invariants": invariants, "allowed_paths": allowed_paths, "symbol_scope": symbol_scope,
177
+ "oracle_cmd": oracle_cmd, "_breaker": breaker,
160
178
  }]) # fmt: skip
161
179
  return f"breaker {broke}\nbuilder queued in {queued.split()[0]}"
162
180
  if " started " not in built:
@@ -220,6 +238,12 @@ def start(
220
238
  except subprocess.CalledProcessError as exc:
221
239
  raise _Compensate(f"worktree not created: {exc.stderr.strip()[:200]}") from exc
222
240
  task += f"\n\nYou work in an isolated git worktree on branch {branch}. Commit your work there; never push."
241
+ if allowed_paths:
242
+ task += "\nChange only these paths; anything else is reverted: " + ", ".join(allowed_paths)
243
+ if symbol_scope:
244
+ task += "\nIn those Python files change only these top-level symbols: " + ", ".join(
245
+ symbol_scope
246
+ )
223
247
  else:
224
248
  workdir = str(Path(cwd or repo or os.getcwd()).resolve())
225
249
  if not Path(workdir).is_dir():
@@ -243,6 +267,10 @@ def start(
243
267
  "policy": {**wanted, "effective": effective, "why": why},
244
268
  "verify": verify,
245
269
  "max_retries": max_retries,
270
+ "invariants": inv.DEFAULT if invariants is None else invariants,
271
+ "allowed_paths": allowed_paths,
272
+ "symbol_scope": symbol_scope,
273
+ "oracle_cmd": oracle_cmd,
246
274
  "breaker": _breaker,
247
275
  "port": _free_port() if branch else None,
248
276
  "group": _group,
@@ -559,7 +587,8 @@ def wait(
559
587
 
560
588
  for group_id in group_ids:
561
589
  groups.cancel(group_id, f"cancelled: first_success {winner}")
562
- losers = [j for j in job_ids if j != winner and j not in ended]
590
+ job_ids, _ = _expand(asked) # a group job started while we looked is a loser too
591
+ losers = [j for j in job_ids if j != winner and jobs.status(j).get("state") not in jobs.FINAL]
563
592
  for job_id in losers:
564
593
  jobs.stop(job_id)
565
594
  head = f"first success: {winner}" + (f" · stopped {', '.join(losers)}" if losers else "")
@@ -571,6 +600,83 @@ def wait(
571
600
  return ("no job succeeded\n\n" + out) if mode == "first_success" else out
572
601
 
573
602
 
603
+ def bisect(
604
+ good_ref: str,
605
+ bad_ref: str,
606
+ test_cmd: str,
607
+ parallelism: int = 4,
608
+ auto_fix: bool = False,
609
+ repo: str | None = None,
610
+ engine: str = "auto",
611
+ max_usd: float | None = None,
612
+ ) -> str:
613
+ """Find the first commit between good_ref and bad_ref that makes test_cmd fail."""
614
+ from . import bisect_tool
615
+
616
+ if not (isinstance(test_cmd, str) and test_cmd.strip()):
617
+ return "test_cmd must be a non-empty command"
618
+ if isinstance(parallelism, bool) or not isinstance(parallelism, int) or not 1 <= parallelism <= 8:
619
+ return "parallelism must be between 1 and 8"
620
+ root = _repo_of(str(Path(repo or os.getcwd()).resolve()))
621
+ if not root:
622
+ return "bisect needs a git repository"
623
+ found = bisect_tool.find(root, good_ref, bad_ref, test_cmd, parallelism)
624
+ text = bisect_tool.report(root, found)
625
+ if found.get("error") or not auto_fix:
626
+ return text
627
+ diff = text.split("\n\n", 1)[-1]
628
+ task = bisect_tool.FIX_TASK.format(sha=found["commit"][:12], cmd=test_cmd, diff=diff)
629
+ out = start(task, engine, root, base="HEAD", max_usd=max_usd, verify=test_cmd, max_retries=1)
630
+ return text[:2000] + f"\n\nfix job: {out}"
631
+
632
+
633
+ CONSENSUS_TASK = (
634
+ "Several engines solved the task below independently, each on its own branch. Their results are "
635
+ "attached as Patch A, Patch B, ... (summary and diff). Compare them, keep the strongest ideas, drop "
636
+ "the bugs of each, and write ONE solution in your worktree. Do not merge the branches. Commit, run "
637
+ "the checks, and finish with a ## Summary saying what you took from which patch.\n\nTask:\n"
638
+ )
639
+
640
+
641
+ def consensus(
642
+ prompt: str,
643
+ engines: list[str],
644
+ verify_cmd: str | None = None,
645
+ isolation: str = "worktree",
646
+ repo: str | None = None,
647
+ cwd: str | None = None,
648
+ max_usd: float | None = None,
649
+ synth_engine: str = "auto",
650
+ ) -> str:
651
+ """Round 1: every engine works blind in its own worktree. Round 2: one job merges the best."""
652
+ if isolation != "worktree":
653
+ return "consensus needs isolation=worktree"
654
+ if not isinstance(engines, list) or not 2 <= len(engines) <= 4 or len(set(engines)) != len(engines):
655
+ return "engines must list 2 to 4 different engines"
656
+ unknown = [e for e in engines if registry.get(e) is None]
657
+ if unknown:
658
+ return f"unknown engine: {', '.join(unknown)}"
659
+ if not (isinstance(prompt, str) and prompt.strip()):
660
+ return "prompt must be a non-empty string"
661
+ root = _repo_of(str(Path(repo or cwd or os.getcwd()).resolve()))
662
+ if not root:
663
+ return "consensus needs a git repository"
664
+ base = {"repo": root, "max_usd": max_usd, "verify": verify_cmd}
665
+ rounds = [{"id": f"r{i + 1}", "task": prompt, "engine": e, **base} for i, e in enumerate(engines)]
666
+ refs = []
667
+ for i in range(len(engines)):
668
+ refs += [f"subtask:r{i + 1}:summary", f"subtask:r{i + 1}:diff"]
669
+ labels = ", ".join(f"Patch {chr(65 + i)} = subtask:r{i + 1}:diff" for i in range(len(engines)))
670
+ synth = {
671
+ "id": "synthesis", "depends_on": [r["id"] for r in rounds], "after": "any", "engine": synth_engine,
672
+ "task": CONSENSUS_TASK + prompt + f"\n\n({labels}; the engine names are hidden on purpose.)",
673
+ "context_refs": refs, **base,
674
+ } # fmt: skip
675
+ from . import groups
676
+
677
+ return groups.create(rounds + [synth], label="consensus")
678
+
679
+
574
680
  def start_many(tasks: list[dict], progress=None) -> str:
575
681
  """Start several jobs in one call; each dict takes start()'s arguments.
576
682
 
@@ -635,6 +741,12 @@ def result(job_id: str, full: bool = False, evidence: bool = False, _count: bool
635
741
  except subprocess.CalledProcessError:
636
742
  pass
637
743
  verification = st.get("verification")
744
+ if verification and verification.get("oracle"):
745
+ from . import oracle
746
+
747
+ head += "\n" + oracle.line(verification["oracle"])
748
+ if verification and verification.get("invariants"):
749
+ head += "\ninvariants violated: " + "; ".join(verification["invariants"][:5])
638
750
  if verification:
639
751
  code = verification.get("exit_code")
640
752
  head += (
@@ -933,6 +1045,16 @@ def steps(limit: int = 10) -> str:
933
1045
  return "\n".join(f"{r['ts']} {Path(r['repo']).name}: {r['title']} ({r['path']})" for r in rows)
934
1046
 
935
1047
 
1048
+ def _oracle_short(report: dict | None) -> dict | None:
1049
+ if not report:
1050
+ return None
1051
+ if report.get("error"):
1052
+ return {"error": report["error"]}
1053
+ return {"output_match": bool(report.get("output_match") and report.get("exit_match")),
1054
+ "speedup_pct": report.get("speedup_pct"), "ms_before": report.get("duration_ms_before"),
1055
+ "ms_after": report.get("duration_ms_after")} # fmt: skip
1056
+
1057
+
936
1058
  def board_data(limit: int = 10) -> dict:
937
1059
  """What a client panel shows: active groups with their queue, recent jobs, verify and budget."""
938
1060
  from . import groups
@@ -947,7 +1069,8 @@ def board_data(limit: int = 10) -> dict:
947
1069
  "id": path.stem, "kind": g.get("kind"), "label": g.get("label"),
948
1070
  "summary": groups.summary(path.stem),
949
1071
  "tasks": [{"alias": t["alias"], "state": t["state"], "job": t.get("job"),
950
- "depends_on": t.get("depends_on", []), "why": t.get("why", "")} for t in tasks],
1072
+ "depends_on": t.get("depends_on", []), "why": t.get("why", ""),
1073
+ "expanded_from": t.get("expanded_from")} for t in tasks],
951
1074
  }) # fmt: skip
952
1075
  job_rows = []
953
1076
  for job_id in jobs.all_jobs()[:limit]:
@@ -963,11 +1086,16 @@ def board_data(limit: int = 10) -> dict:
963
1086
  "retrying": verifier.pending(st), "rolled_back_to": st.get("rolled_back_to"),
964
1087
  "stalled": watchdog.check(job_id, st), "started": sp.get("created"),
965
1088
  "budget_warning": sp.get("budget_warning"),
1089
+ "invariants": v.get("invariants") or [], "reverted": v.get("reverted") or [],
1090
+ "oracle": _oracle_short(v.get("oracle")),
966
1091
  }) # fmt: skip
1092
+ from . import bisect_tool
1093
+
967
1094
  spent = ledger.committed()
968
1095
  return {
969
1096
  "groups": group_rows,
970
1097
  "jobs": job_rows,
1098
+ "bisects": bisect_tool.recent(),
971
1099
  "budget": {"cap_usd": config.MONTHLY_CAP_USD, "committed_usd": round(spent, 2),
972
1100
  "left_usd": round(config.MONTHLY_CAP_USD - spent, 2)},
973
1101
  } # fmt: skip
@@ -980,8 +1108,9 @@ def board(limit: int = 10) -> str:
980
1108
  lines.append(f"{g['id']} {g['kind']} {g['label'] or ''} · {g['summary']}".replace(" ", " "))
981
1109
  for t in g["tasks"]:
982
1110
  deps = f" ← {', '.join(t['depends_on'])}" if t["depends_on"] else ""
1111
+ fan = f" (from {t['expanded_from']})" if t.get("expanded_from") else ""
983
1112
  lines.append(
984
- f" {t['alias']}: {t['job'] or t['state']}{deps}{' · ' + t['why'] if t['why'] else ''}"
1113
+ f" {t['alias']}{fan}: {t['job'] or t['state']}{deps}{' · ' + t['why'] if t['why'] else ''}"
985
1114
  )
986
1115
  for j in data["jobs"]:
987
1116
  bits = [j["state"], j["engine"] or "?"]
@@ -993,7 +1122,21 @@ def board(limit: int = 10) -> str:
993
1122
  bits.append(f"rolled back to {j['rolled_back_to']}")
994
1123
  if j["stalled"]:
995
1124
  bits.append(f"STALLED {j['stalled']}")
1125
+ if j["invariants"]:
1126
+ bits.append(f"INVARIANT {j['invariants'][0][:60]}")
1127
+ if j["reverted"]:
1128
+ bits.append(f"reverted {len(j['reverted'])} file(s) outside allowed_paths")
1129
+ o = j["oracle"]
1130
+ if o:
1131
+ same = "error" if o.get("error") else "same output" if o["output_match"] else "OUTPUT DIFFERS"
1132
+ pct = o.get("speedup_pct")
1133
+ bits.append(f"oracle {same}" + ("" if pct is None else f" {pct:+.0f}%"))
996
1134
  lines.append(f"{j['id']} " + " · ".join(bits))
1135
+ for b in data["bisects"]:
1136
+ found = f" → {b['commit'][:9]}" if b.get("commit") else ""
1137
+ lines.append(
1138
+ f"{b['id']} bisect {b['state']} · round {b['round']} · {b['tested']}/{b['candidates']} tested{found}"
1139
+ )
997
1140
  b = data["budget"]
998
1141
  lines.append(f"budget: {b['left_usd']:.2f}$ left of {b['cap_usd']:.0f}$")
999
1142
  return "\n".join(lines)
@@ -132,6 +132,16 @@ def _call(name: str, args: dict, call: Call) -> str:
132
132
  from . import integrate
133
133
 
134
134
  return integrate.integrate(args["ids"], args.get("action", "apply"), args.get("auto_resolve", False))
135
+ if name == "consensus":
136
+ known = {"prompt", "engines", "verify_cmd", "isolation", "repo", "max_usd"}
137
+ if set(args) - known:
138
+ raise TypeError(f"unexpected argument(s): {', '.join(sorted(set(args) - known))}")
139
+ return core.consensus(**args)
140
+ if name == "bisect":
141
+ known = {"good_ref", "bad_ref", "test_cmd", "parallelism", "auto_fix", "repo"}
142
+ if set(args) - known:
143
+ raise TypeError(f"unexpected argument(s): {', '.join(sorted(set(args) - known))}")
144
+ return core.bisect(**args)
135
145
  if name == "inspect":
136
146
  return core.inspect(args["id"], args.get("tail", 80), args.get("grep"))
137
147
  if name == "steer":
@@ -155,7 +165,7 @@ def _call(name: str, args: dict, call: Call) -> str:
155
165
  START_ARGS = {
156
166
  "task", "kind", "repo", "cwd", "files", "readonly", "engine", "max_usd", "effort",
157
167
  "base", "max_turns", "policy", "verify", "isolation", "max_retries", "adversarial",
158
- "context_refs", "auto_repomap", "tier",
168
+ "context_refs", "auto_repomap", "tier", "invariants", "allowed_paths", "symbol_scope", "oracle_cmd",
159
169
  } # fmt: skip
160
170
 
161
171