cluxion-agentplugin-supercoder 0.2.0__cp311-abi3-win_amd64.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- cluxion_agentplugin_supercoder/__init__.py +5 -0
- cluxion_agentplugin_supercoder/cli.py +32 -0
- cluxion_agentplugin_supercoder/core/cursor.py +82 -0
- cluxion_agentplugin_supercoder/core/hash_patch.py +150 -0
- cluxion_agentplugin_supercoder/core/line_budget.py +56 -0
- cluxion_agentplugin_supercoder/core/lint_gate.py +111 -0
- cluxion_agentplugin_supercoder/core/queue.py +96 -0
- cluxion_agentplugin_supercoder/core/repo_map.py +220 -0
- cluxion_agentplugin_supercoder/core/retry_loop.py +104 -0
- cluxion_agentplugin_supercoder/core/safety.py +61 -0
- cluxion_agentplugin_supercoder/core/syntax_gate.py +112 -0
- cluxion_agentplugin_supercoder/core/test_gate.py +207 -0
- cluxion_agentplugin_supercoder/plugin.py +119 -0
- cluxion_agentplugin_supercoder/runner.py +190 -0
- cluxion_agentplugin_supercoder/rust_bridge.py +158 -0
- cluxion_agentplugin_supercoder/schemas.py +154 -0
- cluxion_agentplugin_supercoder-0.2.0.dist-info/METADATA +106 -0
- cluxion_agentplugin_supercoder-0.2.0.dist-info/RECORD +22 -0
- cluxion_agentplugin_supercoder-0.2.0.dist-info/WHEEL +4 -0
- cluxion_agentplugin_supercoder-0.2.0.dist-info/entry_points.txt +5 -0
- supercoder_index_native/__init__.py +5 -0
- supercoder_index_native/supercoder_index_native.pyd +0 -0
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import json
|
|
5
|
+
import sys
|
|
6
|
+
from collections.abc import Sequence
|
|
7
|
+
|
|
8
|
+
from cluxion_agentplugin_supercoder import __version__
|
|
9
|
+
from cluxion_agentplugin_supercoder.rust_bridge import index_available, resolve_backend
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
13
|
+
parser = argparse.ArgumentParser(prog="cluxion-supercoder")
|
|
14
|
+
parser.add_argument("--version", action="version", version=f"cluxion-agentplugin-supercoder {__version__}")
|
|
15
|
+
sub = parser.add_subparsers(dest="command")
|
|
16
|
+
sub.add_parser("check", help="Check plugin and Rust index availability")
|
|
17
|
+
args = parser.parse_args(argv)
|
|
18
|
+
if args.command == "check":
|
|
19
|
+
payload = {
|
|
20
|
+
"plugin": "cluxion-agentplugin-supercoder",
|
|
21
|
+
"version": __version__,
|
|
22
|
+
"rust_index": index_available(),
|
|
23
|
+
"index_backend": resolve_backend(),
|
|
24
|
+
}
|
|
25
|
+
print(json.dumps(payload, ensure_ascii=False, sort_keys=True))
|
|
26
|
+
return 0
|
|
27
|
+
parser.print_help(sys.stderr)
|
|
28
|
+
return 2
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
if __name__ == "__main__":
|
|
32
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""Cursor logic — bounded file windows with hash verification."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from cluxion_agentplugin_supercoder.core.hash_patch import file_hash
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass(frozen=True)
|
|
12
|
+
class LineWindow:
|
|
13
|
+
path: str
|
|
14
|
+
start_line: int
|
|
15
|
+
end_line: int
|
|
16
|
+
content: str
|
|
17
|
+
content_hash: str
|
|
18
|
+
file_hash: str
|
|
19
|
+
purpose: str = "read"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def read_window(
|
|
23
|
+
root: Path,
|
|
24
|
+
rel_path: str,
|
|
25
|
+
*,
|
|
26
|
+
start_line: int = 1,
|
|
27
|
+
max_lines: int = 120,
|
|
28
|
+
purpose: str = "read",
|
|
29
|
+
) -> LineWindow:
|
|
30
|
+
path = (root / rel_path).resolve()
|
|
31
|
+
if not path.exists():
|
|
32
|
+
raise FileNotFoundError(rel_path)
|
|
33
|
+
if not str(path).startswith(str(root.resolve())):
|
|
34
|
+
raise PermissionError("path escapes workspace root")
|
|
35
|
+
text = path.read_text(encoding="utf-8")
|
|
36
|
+
lines = text.splitlines()
|
|
37
|
+
start = max(1, start_line)
|
|
38
|
+
end = min(len(lines), start + max_lines - 1)
|
|
39
|
+
if start > len(lines):
|
|
40
|
+
excerpt = ""
|
|
41
|
+
end = start
|
|
42
|
+
else:
|
|
43
|
+
excerpt = "\n".join(lines[start - 1 : end])
|
|
44
|
+
return LineWindow(
|
|
45
|
+
path=rel_path,
|
|
46
|
+
start_line=start,
|
|
47
|
+
end_line=end,
|
|
48
|
+
content=excerpt,
|
|
49
|
+
content_hash=file_hash(excerpt),
|
|
50
|
+
file_hash=file_hash(text),
|
|
51
|
+
purpose=purpose,
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def cursor_map(root: Path, *, paths: list[str] | None = None, max_files: int = 64) -> list[dict[str, object]]:
|
|
56
|
+
if paths is None:
|
|
57
|
+
from cluxion_agentplugin_supercoder.rust_bridge import scan_repo
|
|
58
|
+
|
|
59
|
+
scanned = scan_repo(root, max_files=max_files)
|
|
60
|
+
return [{**entry, "purpose": "index"} for entry in scanned]
|
|
61
|
+
entries: list[dict[str, object]] = []
|
|
62
|
+
for rel in paths[:max_files]:
|
|
63
|
+
path = root / rel
|
|
64
|
+
if not path.is_file():
|
|
65
|
+
continue
|
|
66
|
+
try:
|
|
67
|
+
text = path.read_text(encoding="utf-8")
|
|
68
|
+
except (OSError, UnicodeDecodeError):
|
|
69
|
+
continue
|
|
70
|
+
lines = text.count("\n") + (1 if text else 0)
|
|
71
|
+
entries.append(
|
|
72
|
+
{
|
|
73
|
+
"path": rel,
|
|
74
|
+
"file_hash": file_hash(text),
|
|
75
|
+
"total_lines": lines,
|
|
76
|
+
"purpose": "index",
|
|
77
|
+
}
|
|
78
|
+
)
|
|
79
|
+
return entries
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
__all__ = ["LineWindow", "cursor_map", "read_window"]
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
"""Hash-verified safe patch — ported from cluxion-os _hash_edit_core."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from difflib import SequenceMatcher
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
DEFAULT_FUZZY_THRESHOLD = 0.86
|
|
11
|
+
MAX_CONTEXT_SCAN = 8
|
|
12
|
+
MAX_LINE_DRIFT = 2
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True, slots=True)
|
|
16
|
+
class PatchResult:
|
|
17
|
+
success: bool
|
|
18
|
+
file_path: str
|
|
19
|
+
strategy: str
|
|
20
|
+
message: str
|
|
21
|
+
expected_hash: str
|
|
22
|
+
matched_hash: str | None = None
|
|
23
|
+
similarity: float = 0.0
|
|
24
|
+
replacements: int = 0
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def file_hash(content: str) -> str:
|
|
28
|
+
return hashlib.sha256(_normalize_newlines(content).encode("utf-8")).hexdigest()
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def hash_block(content: str, context_lines: int) -> str:
|
|
32
|
+
normalized = _normalize_newlines(content)
|
|
33
|
+
material = f"context_lines={context_lines}\0{normalized}"
|
|
34
|
+
return hashlib.sha256(material.encode("utf-8")).hexdigest()
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def apply_patch(
|
|
38
|
+
path: Path,
|
|
39
|
+
*,
|
|
40
|
+
old_text: str,
|
|
41
|
+
new_text: str,
|
|
42
|
+
expected_file_hash: str = "",
|
|
43
|
+
fuzzy_threshold: float = DEFAULT_FUZZY_THRESHOLD,
|
|
44
|
+
) -> PatchResult:
|
|
45
|
+
if not path.exists():
|
|
46
|
+
return _failed(str(path), "missing_file", expected_file_hash, "file not found")
|
|
47
|
+
text = path.read_text(encoding="utf-8")
|
|
48
|
+
current_hash = file_hash(text)
|
|
49
|
+
if expected_file_hash and current_hash != _normalize_hash(expected_file_hash):
|
|
50
|
+
return _failed(str(path), "stale_file", expected_file_hash, "file changed since cursor was created")
|
|
51
|
+
exact = _exact_spans(text, old_text)
|
|
52
|
+
if exact:
|
|
53
|
+
start, end = exact[0]
|
|
54
|
+
return _commit(path, text, start, end, new_text, "exact", expected_file_hash or current_hash, current_hash, 1.0)
|
|
55
|
+
fuzzy = _best_fuzzy_span(text, old_text)
|
|
56
|
+
if fuzzy and fuzzy[3] >= fuzzy_threshold and not fuzzy[4]:
|
|
57
|
+
return _commit(
|
|
58
|
+
path,
|
|
59
|
+
text,
|
|
60
|
+
fuzzy[0],
|
|
61
|
+
fuzzy[1],
|
|
62
|
+
new_text,
|
|
63
|
+
"fuzzy",
|
|
64
|
+
expected_file_hash or current_hash,
|
|
65
|
+
current_hash,
|
|
66
|
+
fuzzy[3],
|
|
67
|
+
)
|
|
68
|
+
return _failed(str(path), "no_match", expected_file_hash or current_hash, "patch target not found")
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _normalize_newlines(content: str) -> str:
|
|
72
|
+
return content.replace("\r\n", "\n").replace("\r", "\n")
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _normalize_hash(value: str) -> str:
|
|
76
|
+
raw = value.strip().lower()
|
|
77
|
+
if raw.startswith("sha256:"):
|
|
78
|
+
raw = raw.removeprefix("sha256:")
|
|
79
|
+
if len(raw) != 64:
|
|
80
|
+
raise ValueError("hash must be 64-char sha256")
|
|
81
|
+
return raw
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _exact_spans(text: str, needle: str) -> list[tuple[int, int]]:
|
|
85
|
+
spans: list[tuple[int, int]] = []
|
|
86
|
+
offset = 0
|
|
87
|
+
while True:
|
|
88
|
+
start = text.find(needle, offset)
|
|
89
|
+
if start < 0:
|
|
90
|
+
return spans
|
|
91
|
+
spans.append((start, start + len(needle)))
|
|
92
|
+
offset = start + len(needle)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _candidate_spans(text: str, reference: str, line_drift: int) -> list[tuple[int, int, str]]:
|
|
96
|
+
lines = text.splitlines(keepends=True)
|
|
97
|
+
if not lines:
|
|
98
|
+
return []
|
|
99
|
+
offsets = [0]
|
|
100
|
+
for line in lines:
|
|
101
|
+
offsets.append(offsets[-1] + len(line))
|
|
102
|
+
target = max(1, len(reference.splitlines(keepends=True)))
|
|
103
|
+
lower = max(1, target - line_drift)
|
|
104
|
+
upper = min(len(lines), target + line_drift)
|
|
105
|
+
spans: list[tuple[int, int, str]] = []
|
|
106
|
+
for width in range(lower, upper + 1):
|
|
107
|
+
for start_line in range(0, len(lines) - width + 1):
|
|
108
|
+
start = offsets[start_line]
|
|
109
|
+
end = offsets[start_line + width]
|
|
110
|
+
block = text[start:end]
|
|
111
|
+
spans.append((start, end, block))
|
|
112
|
+
return spans
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _best_fuzzy_span(text: str, reference: str) -> tuple[int, int, str, float, bool] | None:
|
|
116
|
+
best: tuple[int, int, str, float] | None = None
|
|
117
|
+
ambiguous = False
|
|
118
|
+
for start, end, block in _candidate_spans(text, reference, MAX_LINE_DRIFT):
|
|
119
|
+
score = SequenceMatcher(None, block, reference, autojunk=False).ratio()
|
|
120
|
+
if best is None or score > best[3]:
|
|
121
|
+
best = (start, end, block, score)
|
|
122
|
+
ambiguous = False
|
|
123
|
+
elif score >= DEFAULT_FUZZY_THRESHOLD and best and abs(score - best[3]) < 0.015:
|
|
124
|
+
ambiguous = True
|
|
125
|
+
if best is None:
|
|
126
|
+
return None
|
|
127
|
+
return best[0], best[1], best[2], best[3], ambiguous
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _commit(
|
|
131
|
+
path: Path,
|
|
132
|
+
text: str,
|
|
133
|
+
start: int,
|
|
134
|
+
end: int,
|
|
135
|
+
new_content: str,
|
|
136
|
+
strategy: str,
|
|
137
|
+
expected: str,
|
|
138
|
+
matched: str,
|
|
139
|
+
score: float,
|
|
140
|
+
) -> PatchResult:
|
|
141
|
+
updated = f"{text[:start]}{new_content}{text[end:]}"
|
|
142
|
+
path.write_text(updated, encoding="utf-8")
|
|
143
|
+
return PatchResult(True, str(path), strategy, "patch applied", expected, matched, round(score, 4), 1)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _failed(path: str, strategy: str, expected: str, message: str, score: float = 0.0) -> PatchResult:
|
|
147
|
+
return PatchResult(False, path, strategy, message, expected, None, round(score, 4), 0)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
__all__ = ["PatchResult", "apply_patch", "file_hash", "hash_block"]
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Line budget policy — blocks oversized reads and writes."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass(frozen=True)
|
|
9
|
+
class BudgetDecision:
|
|
10
|
+
allowed: bool
|
|
11
|
+
reason: str
|
|
12
|
+
max_lines: int
|
|
13
|
+
remaining_lines: int
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
_DEFAULTS = {
|
|
17
|
+
"inspect": 120,
|
|
18
|
+
"patch_context": 100,
|
|
19
|
+
"review": 160,
|
|
20
|
+
"refactor_unit": 250,
|
|
21
|
+
"create_file": 400,
|
|
22
|
+
"test_log": 120,
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def budget_for(mode: str, *, requested_lines: int, remaining: int = 10_000) -> BudgetDecision:
|
|
27
|
+
cap = _DEFAULTS.get(mode, 120)
|
|
28
|
+
if requested_lines > cap:
|
|
29
|
+
return BudgetDecision(False, f"line_budget_exceeded:{mode}", cap, remaining)
|
|
30
|
+
if requested_lines > remaining:
|
|
31
|
+
return BudgetDecision(False, "session_line_budget_exhausted", cap, remaining)
|
|
32
|
+
return BudgetDecision(True, "within_budget", cap, remaining - requested_lines)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def is_coding_task(prompt: str) -> bool:
|
|
36
|
+
text = prompt.lower()
|
|
37
|
+
needles = (
|
|
38
|
+
"code",
|
|
39
|
+
"fix",
|
|
40
|
+
"implement",
|
|
41
|
+
"refactor",
|
|
42
|
+
"patch",
|
|
43
|
+
"test",
|
|
44
|
+
"bug",
|
|
45
|
+
"코드",
|
|
46
|
+
"수정",
|
|
47
|
+
"구현",
|
|
48
|
+
"리팩터",
|
|
49
|
+
"패치",
|
|
50
|
+
"테스트",
|
|
51
|
+
"버그",
|
|
52
|
+
)
|
|
53
|
+
return any(needle in text for needle in needles)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
__all__ = ["BudgetDecision", "budget_for", "is_coding_task"]
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""L2 lint gate: advisory lint findings for the file a patch just changed.
|
|
2
|
+
|
|
3
|
+
The engine is ruff — a Rust linter shipped as a wheel dependency of this
|
|
4
|
+
plugin, so the gate works everywhere without asking the host project to
|
|
5
|
+
install anything. It runs on a single file, respects the target project's
|
|
6
|
+
own ruff configuration (config discovery walks up from the file), and is
|
|
7
|
+
suggest-only: findings ride along on the patch result and never block or
|
|
8
|
+
revert a patch. Languages without a wired linter report ``checked: False``.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
import os
|
|
15
|
+
import shutil
|
|
16
|
+
import subprocess
|
|
17
|
+
import sys
|
|
18
|
+
from functools import lru_cache
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Any
|
|
21
|
+
|
|
22
|
+
from cluxion_agentplugin_supercoder.core.syntax_gate import language_for_path
|
|
23
|
+
|
|
24
|
+
RUFF_BIN_ENV = "CLUXION_SUPERCODER_RUFF_BIN"
|
|
25
|
+
LINTABLE_LANGUAGES = {"python"}
|
|
26
|
+
MAX_REPORTED_FINDINGS = 20
|
|
27
|
+
_TIMEOUT_SECONDS = 15.0
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def ruff_bin() -> str | None:
|
|
31
|
+
"""Resolve the ruff executable: env override, venv sibling, then PATH."""
|
|
32
|
+
override = os.environ.get(RUFF_BIN_ENV, "").strip()
|
|
33
|
+
if override:
|
|
34
|
+
return override if Path(override).exists() else None
|
|
35
|
+
return _discover_ruff()
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@lru_cache(maxsize=1)
|
|
39
|
+
def _discover_ruff() -> str | None:
|
|
40
|
+
name = "ruff.exe" if os.name == "nt" else "ruff"
|
|
41
|
+
sibling = Path(sys.executable).with_name(name)
|
|
42
|
+
if sibling.exists():
|
|
43
|
+
return str(sibling)
|
|
44
|
+
return shutil.which("ruff")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def check_file(path: str | Path, *, cwd: str | Path | None = None) -> dict[str, Any]:
|
|
48
|
+
"""Lint one file and return structured advisory findings."""
|
|
49
|
+
target = Path(path)
|
|
50
|
+
language = language_for_path(target)
|
|
51
|
+
if language not in LINTABLE_LANGUAGES:
|
|
52
|
+
return _unchecked(language or "", "no_linter")
|
|
53
|
+
binary = ruff_bin()
|
|
54
|
+
if binary is None:
|
|
55
|
+
return _unchecked(language, "no_tool")
|
|
56
|
+
command = [binary, "check", "--output-format", "json", "--force-exclude", "--no-cache", str(target)]
|
|
57
|
+
try:
|
|
58
|
+
proc = subprocess.run(
|
|
59
|
+
command,
|
|
60
|
+
cwd=str(cwd) if cwd is not None else None,
|
|
61
|
+
capture_output=True,
|
|
62
|
+
text=True,
|
|
63
|
+
timeout=_TIMEOUT_SECONDS,
|
|
64
|
+
)
|
|
65
|
+
except (OSError, subprocess.TimeoutExpired) as exc:
|
|
66
|
+
return _unchecked(language, f"tool_error:{type(exc).__name__}")
|
|
67
|
+
# ruff exits 0 (clean) or 1 (findings); anything else is a tool failure.
|
|
68
|
+
if proc.returncode not in (0, 1):
|
|
69
|
+
return _unchecked(language, "tool_error:exit")
|
|
70
|
+
try:
|
|
71
|
+
raw = json.loads(proc.stdout or "[]")
|
|
72
|
+
except json.JSONDecodeError:
|
|
73
|
+
return _unchecked(language, "tool_error:output")
|
|
74
|
+
findings = [
|
|
75
|
+
{
|
|
76
|
+
"line": int(item.get("location", {}).get("row", 1)),
|
|
77
|
+
"column": int(item.get("location", {}).get("column", 1)),
|
|
78
|
+
"code": item.get("code") or "",
|
|
79
|
+
"message": str(item.get("message", "")),
|
|
80
|
+
"fixable": item.get("fix") is not None,
|
|
81
|
+
}
|
|
82
|
+
for item in raw
|
|
83
|
+
if isinstance(item, dict)
|
|
84
|
+
]
|
|
85
|
+
total = len(findings)
|
|
86
|
+
return {
|
|
87
|
+
"ok": True,
|
|
88
|
+
"checked": True,
|
|
89
|
+
"language": language,
|
|
90
|
+
"tool": "ruff",
|
|
91
|
+
"clean": total == 0,
|
|
92
|
+
"findings": findings[:MAX_REPORTED_FINDINGS],
|
|
93
|
+
"finding_count": total,
|
|
94
|
+
"truncated": total > MAX_REPORTED_FINDINGS,
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _unchecked(language: str, reason: str) -> dict[str, Any]:
|
|
99
|
+
return {
|
|
100
|
+
"ok": True,
|
|
101
|
+
"checked": False,
|
|
102
|
+
"language": language,
|
|
103
|
+
"reason": reason,
|
|
104
|
+
"clean": True,
|
|
105
|
+
"findings": [],
|
|
106
|
+
"finding_count": 0,
|
|
107
|
+
"truncated": False,
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
__all__ = ["LINTABLE_LANGUAGES", "MAX_REPORTED_FINDINGS", "RUFF_BIN_ENV", "check_file", "ruff_bin"]
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""Coding work unit queue — deterministic, no model calls."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from enum import StrEnum
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class WorkStatus(StrEnum):
|
|
10
|
+
PENDING = "pending"
|
|
11
|
+
RUNNING = "running"
|
|
12
|
+
BLOCKED = "blocked"
|
|
13
|
+
DEFERRED = "deferred"
|
|
14
|
+
COMPLETE = "complete"
|
|
15
|
+
FAILED = "failed"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class WorkUnit:
|
|
20
|
+
id: str
|
|
21
|
+
goal: str
|
|
22
|
+
priority: int = 2
|
|
23
|
+
allowed_paths: tuple[str, ...] = ()
|
|
24
|
+
line_budget: int = 250
|
|
25
|
+
status: WorkStatus = WorkStatus.PENDING
|
|
26
|
+
expected_evidence: tuple[str, ...] = ()
|
|
27
|
+
dependencies: tuple[str, ...] = ()
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class TaskQueue:
|
|
32
|
+
task_id: str
|
|
33
|
+
units: list[WorkUnit] = field(default_factory=list)
|
|
34
|
+
|
|
35
|
+
def enqueue(self, unit: WorkUnit) -> None:
|
|
36
|
+
self.units.append(unit)
|
|
37
|
+
|
|
38
|
+
def next_unit(self) -> WorkUnit | None:
|
|
39
|
+
completed = {unit.id for unit in self.units if unit.status == WorkStatus.COMPLETE}
|
|
40
|
+
for unit in sorted(self.units, key=lambda item: (item.priority, item.id)):
|
|
41
|
+
if unit.status != WorkStatus.PENDING:
|
|
42
|
+
continue
|
|
43
|
+
if all(dep in completed for dep in unit.dependencies):
|
|
44
|
+
unit.status = WorkStatus.RUNNING
|
|
45
|
+
return unit
|
|
46
|
+
return None
|
|
47
|
+
|
|
48
|
+
def record(self, unit_id: str, *, success: bool, evidence: tuple[str, ...] = ()) -> dict[str, object]:
|
|
49
|
+
for unit in self.units:
|
|
50
|
+
if unit.id != unit_id:
|
|
51
|
+
continue
|
|
52
|
+
unit.status = WorkStatus.COMPLETE if success else WorkStatus.FAILED
|
|
53
|
+
unit.expected_evidence = evidence or unit.expected_evidence
|
|
54
|
+
return {
|
|
55
|
+
"task_id": self.task_id,
|
|
56
|
+
"unit_id": unit_id,
|
|
57
|
+
"status": unit.status.value,
|
|
58
|
+
"remaining": sum(1 for item in self.units if item.status == WorkStatus.PENDING),
|
|
59
|
+
}
|
|
60
|
+
raise KeyError(unit_id)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def plan_coding_task(task_id: str, prompt: str) -> TaskQueue:
|
|
64
|
+
queue = TaskQueue(task_id=task_id)
|
|
65
|
+
queue.enqueue(WorkUnit("map", "Map repo and identify target files", priority=0, expected_evidence=("cursor_map",)))
|
|
66
|
+
queue.enqueue(
|
|
67
|
+
WorkUnit(
|
|
68
|
+
"edit",
|
|
69
|
+
f"Apply focused changes for: {prompt[:240]}",
|
|
70
|
+
priority=1,
|
|
71
|
+
dependencies=("map",),
|
|
72
|
+
expected_evidence=("files_changed",),
|
|
73
|
+
)
|
|
74
|
+
)
|
|
75
|
+
queue.enqueue(
|
|
76
|
+
WorkUnit(
|
|
77
|
+
"verify",
|
|
78
|
+
"Run targeted tests or lint for changed files",
|
|
79
|
+
priority=2,
|
|
80
|
+
dependencies=("edit",),
|
|
81
|
+
expected_evidence=("tests_run",),
|
|
82
|
+
)
|
|
83
|
+
)
|
|
84
|
+
queue.enqueue(
|
|
85
|
+
WorkUnit(
|
|
86
|
+
"brief",
|
|
87
|
+
"Summarize changes, verification, and remaining risks",
|
|
88
|
+
priority=3,
|
|
89
|
+
dependencies=("verify",),
|
|
90
|
+
expected_evidence=("brief",),
|
|
91
|
+
)
|
|
92
|
+
)
|
|
93
|
+
return queue
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
__all__ = ["TaskQueue", "WorkStatus", "WorkUnit", "plan_coding_task"]
|