caokit 0.3.0.dev0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- caokit/__init__.py +3 -0
- caokit/adapters.py +259 -0
- caokit/cli.py +704 -0
- caokit/company.py +115 -0
- caokit/config.py +191 -0
- caokit/data/adapters/antigravity.toml +35 -0
- caokit/data/adapters/claude.toml +38 -0
- caokit/data/adapters/codex.toml +33 -0
- caokit/data/roles/engineer.md +90 -0
- caokit/data/roles/general.md +177 -0
- caokit/data/roles/inspector.md +55 -0
- caokit/data/roles/scout.md +77 -0
- caokit/data/scaffold/company/BACKLOG.md +5 -0
- caokit/data/scaffold/company/COMPANY.md +28 -0
- caokit/data/scaffold/company/DECISIONS.md +5 -0
- caokit/data/scaffold/company/README.md +16 -0
- caokit/data/scaffold/company/TEAM.md +7 -0
- caokit/data/scaffold/company/contracts/ARTIFACT_CONTRACT.md +44 -0
- caokit/data/scaffold/company/contracts/COMMUNICATION_CONTRACT.md +38 -0
- caokit/data/scaffold/company/contracts/COMPANY_CONTRACT.md +42 -0
- caokit/data/scaffold/company/contracts/REVIEW_CONTRACT.md +36 -0
- caokit/data/scaffold/company/contracts/SAFETY_CONTRACT.md +40 -0
- caokit/data/scaffold/company/contracts/STATE_CONTRACT.md +31 -0
- caokit/data/scaffold/company/contracts/TASK_CONTRACT.md +26 -0
- caokit/data/scaffold/company/prompts/first-run.md +18 -0
- caokit/data/scaffold/company/tasks/TASK-0001-smoke-test.md +109 -0
- caokit/data/scaffold/company/tasks/TASK_TEMPLATE.md +73 -0
- caokit/doctor.py +287 -0
- caokit/gitignore.py +36 -0
- caokit/init_project.py +183 -0
- caokit/install.py +242 -0
- caokit/launch.py +102 -0
- caokit/project.py +359 -0
- caokit/render.py +129 -0
- caokit/reset.py +208 -0
- caokit/roster.py +95 -0
- caokit/schedule.py +104 -0
- caokit/stack.py +117 -0
- caokit/tasks.py +180 -0
- caokit/worktrees.py +107 -0
- caokit-0.3.0.dev0.dist-info/METADATA +382 -0
- caokit-0.3.0.dev0.dist-info/RECORD +46 -0
- caokit-0.3.0.dev0.dist-info/WHEEL +5 -0
- caokit-0.3.0.dev0.dist-info/entry_points.txt +2 -0
- caokit-0.3.0.dev0.dist-info/licenses/LICENSE +21 -0
- caokit-0.3.0.dev0.dist-info/top_level.txt +1 -0
caokit/roster.py
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""Roster rules (guardrails) that decide whether a Config is allowed."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
|
|
5
|
+
from .adapters import Adapter, AdapterError, load_adapter, vendor_of
|
|
6
|
+
from .config import Config
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass
|
|
10
|
+
class Result:
|
|
11
|
+
"""Outcome of checking a roster: errors block, warnings inform.
|
|
12
|
+
|
|
13
|
+
Attributes:
|
|
14
|
+
errors: Violations that must stop `company create` and `install`.
|
|
15
|
+
warnings: Risks the user should see but may accept.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
errors: list[str] = field(default_factory=list)
|
|
19
|
+
warnings: list[str] = field(default_factory=list)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def check_roster(cfg: Config) -> Result:
|
|
23
|
+
"""Apply every guardrail to ``cfg`` and return all errors and warnings.
|
|
24
|
+
|
|
25
|
+
Rules: tools must exist and not be parked; a tool must be allowed to fill
|
|
26
|
+
the role; a soft-enforcement engineer or General needs an explicit opt-in; the
|
|
27
|
+
inspector must be independent of the engineer (different tool and model
|
|
28
|
+
vendor); a single-provider setup is flagged.
|
|
29
|
+
|
|
30
|
+
Args:
|
|
31
|
+
cfg: The configuration to check.
|
|
32
|
+
|
|
33
|
+
Returns:
|
|
34
|
+
All errors and warnings found; never raises for a bad roster.
|
|
35
|
+
"""
|
|
36
|
+
res = Result()
|
|
37
|
+
adapters: dict[str, Adapter] = {}
|
|
38
|
+
for role, b in cfg.roles.items():
|
|
39
|
+
try:
|
|
40
|
+
a = load_adapter(b.tool)
|
|
41
|
+
except AdapterError:
|
|
42
|
+
res.errors.append(f"roles.{role}: unknown tool {b.tool!r}")
|
|
43
|
+
continue
|
|
44
|
+
adapters[role] = a
|
|
45
|
+
if a.status == "parked":
|
|
46
|
+
res.errors.append(f"roles.{role}: tool {b.tool!r} is parked and cannot be selected")
|
|
47
|
+
continue # no point checking capabilities of an unselectable tool
|
|
48
|
+
if not a.roles[role].can_fill:
|
|
49
|
+
res.errors.append(f"roles.{role}: tool {b.tool!r} cannot fill the {role} role")
|
|
50
|
+
# Soft enforcement means CAO cannot deny tools. The engineer has write + shell, and the
|
|
51
|
+
# General is meant to have neither, so both need an explicit opt-in.
|
|
52
|
+
opt_in = {"engineer": cfg.allow_soft_engineer, "general": cfg.allow_soft_general}
|
|
53
|
+
if role in opt_in and a.enforcement == "soft":
|
|
54
|
+
if not opt_in[role]:
|
|
55
|
+
res.errors.append(
|
|
56
|
+
f"roles.{role}: tool {b.tool!r} has soft enforcement (tool limits are "
|
|
57
|
+
f"advisory); pass --allow-soft-{role} to accept this"
|
|
58
|
+
)
|
|
59
|
+
else:
|
|
60
|
+
res.warnings.append(
|
|
61
|
+
f"{role} uses {b.tool!r}: its tool limits are advisory only, nothing "
|
|
62
|
+
"stops it from writing or running anything it can reach"
|
|
63
|
+
)
|
|
64
|
+
if "engineer" in adapters and "inspector" in adapters:
|
|
65
|
+
_check_cross_provider(cfg, adapters, res)
|
|
66
|
+
if len({b.tool for b in cfg.roles.values()}) == 1:
|
|
67
|
+
res.warnings.append(
|
|
68
|
+
"single provider in use: independent cross-provider review is not possible"
|
|
69
|
+
)
|
|
70
|
+
return res
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _check_cross_provider(cfg: Config, adapters: dict[str, Adapter], res: Result) -> None:
|
|
74
|
+
"""Require the inspector to differ from the engineer in tool AND model vendor.
|
|
75
|
+
|
|
76
|
+
Args:
|
|
77
|
+
cfg: The configuration being checked.
|
|
78
|
+
adapters: Loaded adapters keyed by role (engineer and inspector present).
|
|
79
|
+
res: Result to append errors or warnings to.
|
|
80
|
+
"""
|
|
81
|
+
eb, ib = cfg.roles["engineer"], cfg.roles["inspector"]
|
|
82
|
+
problems: list[str] = []
|
|
83
|
+
if eb.tool == ib.tool:
|
|
84
|
+
problems.append(f"engineer and inspector both use tool {eb.tool!r}")
|
|
85
|
+
# Vendor check catches e.g. an Antigravity inspector running a Claude model.
|
|
86
|
+
ev = vendor_of(adapters["engineer"], eb.model)
|
|
87
|
+
iv = vendor_of(adapters["inspector"], ib.model)
|
|
88
|
+
if ev == iv:
|
|
89
|
+
problems.append(f"engineer and inspector models are the same vendor ({ev})")
|
|
90
|
+
for p in problems:
|
|
91
|
+
msg = "cross-provider inspection violated: " + p
|
|
92
|
+
if cfg.allow_single_provider:
|
|
93
|
+
res.warnings.append(msg)
|
|
94
|
+
else:
|
|
95
|
+
res.errors.append(msg + " (--allow-single-provider to override)")
|
caokit/schedule.py
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""Install a daily cron job that archives finished tasks and prunes old archived ones.
|
|
2
|
+
|
|
3
|
+
cron is the only scheduler used. The entry carries a marker comment holding the project path,
|
|
4
|
+
so installing twice replaces the entry and removing it touches nothing else in the crontab.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import shlex
|
|
8
|
+
import subprocess
|
|
9
|
+
import tempfile
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from .adapters import ProcessResult, Runner, default_runner
|
|
13
|
+
|
|
14
|
+
MARKER = "# caokit-tasks:"
|
|
15
|
+
DEFAULT_TIME = "03:17"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class ScheduleError(Exception):
|
|
19
|
+
"""Raised when the crontab cannot be read or written."""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def cron_line(caokit: str, repo: Path, at: str = DEFAULT_TIME) -> str:
|
|
23
|
+
"""Build the crontab entry for one project.
|
|
24
|
+
|
|
25
|
+
Args:
|
|
26
|
+
caokit: Absolute path of the ``caokit`` executable (cron has a minimal PATH).
|
|
27
|
+
repo: Project root.
|
|
28
|
+
at: Time of day, ``HH:MM`` (24 hour).
|
|
29
|
+
|
|
30
|
+
Returns:
|
|
31
|
+
One crontab line ending in the project marker.
|
|
32
|
+
|
|
33
|
+
Raises:
|
|
34
|
+
ValueError: ``at`` is not a valid ``HH:MM`` time.
|
|
35
|
+
"""
|
|
36
|
+
hh, _, mm = at.partition(":")
|
|
37
|
+
if not (hh.isdigit() and mm.isdigit() and 0 <= int(hh) <= 23 and 0 <= int(mm) <= 59):
|
|
38
|
+
raise ValueError(f"invalid time {at!r}: use HH:MM, 24 hour")
|
|
39
|
+
exe, root = shlex.quote(caokit), shlex.quote(str(repo))
|
|
40
|
+
cmd = f"{exe} tasks archive {root} && {exe} tasks prune {root} --yes"
|
|
41
|
+
return f"{int(mm)} {int(hh)} * * * {_escape(cmd)} {_tag(repo)}"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _escape(text: str) -> str:
|
|
45
|
+
"""Escape ``%``: cron turns an unescaped one into a newline and feeds the rest to stdin."""
|
|
46
|
+
return text.replace("%", "\\%")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _tag(repo: Path) -> str:
|
|
50
|
+
return MARKER + _escape(str(repo))
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _run(runner: Runner, argv: list[str]) -> ProcessResult:
|
|
54
|
+
try:
|
|
55
|
+
return runner(argv)
|
|
56
|
+
except (OSError, subprocess.TimeoutExpired) as e:
|
|
57
|
+
raise ScheduleError(f"could not run {argv[0]}: {e}") from e
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _read(runner: Runner) -> list[str]:
|
|
61
|
+
r = _run(runner, ["crontab", "-l"])
|
|
62
|
+
if r.returncode != 0:
|
|
63
|
+
# Only "no crontab for user" means empty. Anything else must not be read as empty,
|
|
64
|
+
# or the next write would replace the whole crontab with one line.
|
|
65
|
+
if "no crontab" in (r.stderr or "").lower():
|
|
66
|
+
return []
|
|
67
|
+
raise ScheduleError("could not read the crontab: " + (r.stderr or "").strip())
|
|
68
|
+
return (r.stdout or "").splitlines()
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _write(runner: Runner, lines: list[str]) -> None:
|
|
72
|
+
text = "\n".join(lines) + "\n" if lines else ""
|
|
73
|
+
with tempfile.NamedTemporaryFile("w", suffix=".cron", delete=False) as f:
|
|
74
|
+
f.write(text)
|
|
75
|
+
try:
|
|
76
|
+
r = _run(runner, ["crontab", f.name]) # the runner has no stdin, so install from a file
|
|
77
|
+
finally:
|
|
78
|
+
Path(f.name).unlink(missing_ok=True)
|
|
79
|
+
if r.returncode != 0:
|
|
80
|
+
raise ScheduleError("could not write the crontab: " + (r.stderr or "").strip())
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _without(lines: list[str], repo: Path) -> list[str]:
|
|
84
|
+
tag = _tag(repo)
|
|
85
|
+
return [ln for ln in lines if not ln.endswith(tag)]
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def install(line: str, repo: Path, runner: Runner = default_runner) -> None:
|
|
89
|
+
"""Add (or replace) the project's entry, leaving every other crontab line alone."""
|
|
90
|
+
_write(runner, [*_without(_read(runner), repo), line])
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def remove(repo: Path, runner: Runner = default_runner) -> bool:
|
|
94
|
+
"""Remove the project's entry.
|
|
95
|
+
|
|
96
|
+
Returns:
|
|
97
|
+
True if an entry was removed, False if there was none.
|
|
98
|
+
"""
|
|
99
|
+
before = _read(runner)
|
|
100
|
+
after = _without(before, repo)
|
|
101
|
+
if after == before:
|
|
102
|
+
return False
|
|
103
|
+
_write(runner, after)
|
|
104
|
+
return True
|
caokit/stack.py
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""Suggest a project validation command from what is already in the repo.
|
|
2
|
+
|
|
3
|
+
Only suggests; the human confirms at task approval. Prefers forms that leave no
|
|
4
|
+
generated files behind (no .pyc, no pytest cache). Agents never install, so we
|
|
5
|
+
only suggest tooling that is already present.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import re
|
|
10
|
+
import subprocess
|
|
11
|
+
from collections.abc import Sequence
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
from .adapters import ProcessResult, Runner
|
|
16
|
+
|
|
17
|
+
PYTEST_CMD = "python3 -B -m pytest -p no:cacheprovider -q"
|
|
18
|
+
UNITTEST_CMD = "python3 -B -m unittest discover"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class Suggestion:
|
|
23
|
+
"""A proposed validation command; never authorised until the human confirms it."""
|
|
24
|
+
|
|
25
|
+
command: str | None # None when only a hint is possible (e.g. CI file found)
|
|
26
|
+
reason: str # what in the repo led to this suggestion
|
|
27
|
+
note: str = "" # caveat shown to the human, e.g. dependencies not installed
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _default_runner(argv: Sequence[str]) -> ProcessResult:
|
|
31
|
+
"""Run ``argv`` with captured text output and a short timeout."""
|
|
32
|
+
return subprocess.run(list(argv), capture_output=True, text=True, timeout=30)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _read(p: Path) -> str:
|
|
36
|
+
"""Read a text file, returning "" if it is missing or unreadable."""
|
|
37
|
+
try:
|
|
38
|
+
return p.read_text(encoding="utf-8", errors="replace")
|
|
39
|
+
except OSError:
|
|
40
|
+
return ""
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _uses_pytest(root: Path) -> bool:
|
|
44
|
+
"""Return True if the repo configures or depends on pytest."""
|
|
45
|
+
if (root / "pytest.ini").exists() or (root / "conftest.py").exists():
|
|
46
|
+
return True
|
|
47
|
+
for name in (
|
|
48
|
+
"pyproject.toml",
|
|
49
|
+
"setup.cfg",
|
|
50
|
+
"tox.ini",
|
|
51
|
+
"requirements.txt",
|
|
52
|
+
"requirements-dev.txt",
|
|
53
|
+
):
|
|
54
|
+
t = _read(root / name)
|
|
55
|
+
if re.search(r"pytest", t):
|
|
56
|
+
return True
|
|
57
|
+
return False
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _has_py_tests(root: Path) -> bool:
|
|
61
|
+
"""Return True if ``test*.py`` files exist in tests/, test/ or the root."""
|
|
62
|
+
for d in (root / "tests", root / "test", root):
|
|
63
|
+
if d.is_dir() and any(d.glob("test*.py")):
|
|
64
|
+
return True
|
|
65
|
+
return False
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def suggest_validation(root: Path, runner: Runner = _default_runner) -> Suggestion | None:
|
|
69
|
+
"""Suggest how to validate the project at ``root``, or None if nothing is detected.
|
|
70
|
+
|
|
71
|
+
Order: package.json test script, pytest (only if importable), unittest files,
|
|
72
|
+
Makefile ``test`` target, then a CI-workflow hint.
|
|
73
|
+
|
|
74
|
+
Args:
|
|
75
|
+
root: Project root directory.
|
|
76
|
+
runner: Executes ``python3 -c "import pytest"``; injected so tests need no pytest.
|
|
77
|
+
|
|
78
|
+
Returns:
|
|
79
|
+
A :class:`Suggestion`, or None.
|
|
80
|
+
"""
|
|
81
|
+
root = Path(root)
|
|
82
|
+
pkg = root / "package.json"
|
|
83
|
+
if pkg.is_file():
|
|
84
|
+
try:
|
|
85
|
+
script = json.loads(_read(pkg)).get("scripts", {}).get("test", "")
|
|
86
|
+
except (ValueError, AttributeError):
|
|
87
|
+
script = ""
|
|
88
|
+
# `npm init` writes a placeholder script that always fails; ignore it.
|
|
89
|
+
if script and "no test specified" not in script:
|
|
90
|
+
note = (
|
|
91
|
+
""
|
|
92
|
+
if (root / "node_modules").is_dir()
|
|
93
|
+
else "node_modules is missing; agents never install, "
|
|
94
|
+
"install dependencies yourself first"
|
|
95
|
+
)
|
|
96
|
+
return Suggestion("npm test", "package.json defines a test script", note)
|
|
97
|
+
if _has_py_tests(root) or _uses_pytest(root):
|
|
98
|
+
# -B / no:cacheprovider keep the working tree clean (no .pyc or .pytest_cache).
|
|
99
|
+
if _uses_pytest(root):
|
|
100
|
+
# Agents never install anything, so only suggest pytest if it is already present.
|
|
101
|
+
try:
|
|
102
|
+
have = runner(["python3", "-c", "import pytest"]).returncode == 0
|
|
103
|
+
except (OSError, subprocess.SubprocessError):
|
|
104
|
+
have = False
|
|
105
|
+
if have:
|
|
106
|
+
return Suggestion(PYTEST_CMD, "pytest is configured and importable")
|
|
107
|
+
if _has_py_tests(root):
|
|
108
|
+
return Suggestion(UNITTEST_CMD, "test*.py files found")
|
|
109
|
+
mk = _read(root / "Makefile")
|
|
110
|
+
if re.search(r"(?m)^test\s*:", mk):
|
|
111
|
+
return Suggestion("make test", "Makefile has a test target")
|
|
112
|
+
wf = root / ".github" / "workflows"
|
|
113
|
+
if wf.is_dir():
|
|
114
|
+
files = sorted(p.name for p in wf.iterdir() if p.suffix in (".yml", ".yaml"))
|
|
115
|
+
if files:
|
|
116
|
+
return Suggestion(None, f"CI workflow found ({files[0]}): copy its test step")
|
|
117
|
+
return None
|
caokit/tasks.py
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
"""Read-only view of ``.company/tasks`` plus archiving of finished tasks."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import re
|
|
5
|
+
import shutil
|
|
6
|
+
import time
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
TERMINAL = frozenset({"ACCEPTED", "CANCELLED", "FAILED"})
|
|
11
|
+
_ID = re.compile(r"TASK-\d{4}")
|
|
12
|
+
_STATE = re.compile(r"^- \*\*State:\*\*\s*([A-Z_]+)", re.M)
|
|
13
|
+
_DEPS = re.compile(r"^- \*\*Depends on:\*\*(.*)$", re.M)
|
|
14
|
+
_TITLE = re.compile(r"^# (TASK-\d{4}):\s*(.*)$", re.M)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class TaskError(Exception):
|
|
18
|
+
"""Raised for an unreadable task folder or a dependency loop."""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(frozen=True)
|
|
22
|
+
class Task:
|
|
23
|
+
"""One task file, reduced to what listing and archiving need."""
|
|
24
|
+
|
|
25
|
+
id: str
|
|
26
|
+
title: str
|
|
27
|
+
state: str
|
|
28
|
+
depends: tuple[str, ...]
|
|
29
|
+
path: Path
|
|
30
|
+
archived: bool = False
|
|
31
|
+
blocked_by: tuple[str, ...] = field(default=(), compare=False)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def tasks_dir(repo: Path) -> Path:
|
|
35
|
+
"""Return ``<repo>/.company/tasks``, raising if the repo has none."""
|
|
36
|
+
d = repo / ".company" / "tasks"
|
|
37
|
+
if not d.is_dir():
|
|
38
|
+
raise TaskError(f"no .company/tasks folder in {repo}")
|
|
39
|
+
return d
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def parse_task(path: Path, archived: bool = False) -> Task | None:
|
|
43
|
+
"""Parse a task file, or return None if it has no ``# TASK-NNNN`` heading."""
|
|
44
|
+
text = path.read_text(encoding="utf-8", errors="replace")
|
|
45
|
+
title = _TITLE.search(text)
|
|
46
|
+
if not title:
|
|
47
|
+
return None
|
|
48
|
+
state = _STATE.search(text)
|
|
49
|
+
deps = _DEPS.search(text)
|
|
50
|
+
return Task(
|
|
51
|
+
id=title.group(1),
|
|
52
|
+
title=title.group(2).strip(),
|
|
53
|
+
state=state.group(1) if state else "UNKNOWN",
|
|
54
|
+
depends=tuple(dict.fromkeys(_ID.findall(deps.group(1)))) if deps else (),
|
|
55
|
+
path=path,
|
|
56
|
+
archived=archived,
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def load_tasks(repo: Path) -> list[Task]:
|
|
61
|
+
"""Load active tasks and archived ones, sorted by id."""
|
|
62
|
+
root = tasks_dir(repo)
|
|
63
|
+
found = [t for p in root.glob("TASK-*.md") if (t := parse_task(p))]
|
|
64
|
+
arch = root / "archive"
|
|
65
|
+
if arch.is_dir():
|
|
66
|
+
found += [t for p in arch.glob("TASK-*.md") if (t := parse_task(p, archived=True))]
|
|
67
|
+
return sorted(found, key=lambda t: t.id)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def find_cycle(tasks: list[Task]) -> list[str] | None:
|
|
71
|
+
"""Return one dependency loop as ``[A, B, A]``, or None if there is none."""
|
|
72
|
+
graph = {t.id: t.depends for t in tasks}
|
|
73
|
+
done: set[str] = set()
|
|
74
|
+
|
|
75
|
+
def visit(node: str, path: list[str]) -> list[str] | None:
|
|
76
|
+
if node in path:
|
|
77
|
+
return [*path[path.index(node) :], node]
|
|
78
|
+
if node in done or node not in graph:
|
|
79
|
+
return None
|
|
80
|
+
for nxt in graph[node]:
|
|
81
|
+
if (hit := visit(nxt, [*path, node])) is not None:
|
|
82
|
+
return hit
|
|
83
|
+
done.add(node)
|
|
84
|
+
return None
|
|
85
|
+
|
|
86
|
+
for tid in sorted(graph):
|
|
87
|
+
if (hit := visit(tid, [])) is not None:
|
|
88
|
+
return hit
|
|
89
|
+
return None
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def with_blockers(tasks: list[Task]) -> list[Task]:
|
|
93
|
+
"""Fill ``blocked_by``: dependencies that are missing or not ACCEPTED.
|
|
94
|
+
|
|
95
|
+
Raises:
|
|
96
|
+
TaskError: if the dependencies form a loop.
|
|
97
|
+
"""
|
|
98
|
+
if (loop := find_cycle(tasks)) is not None:
|
|
99
|
+
raise TaskError("dependency loop: " + " -> ".join(loop))
|
|
100
|
+
state = {t.id: t.state for t in tasks}
|
|
101
|
+
return [
|
|
102
|
+
Task(
|
|
103
|
+
t.id, t.title, t.state, t.depends, t.path, t.archived,
|
|
104
|
+
tuple(d for d in t.depends if state.get(d) != "ACCEPTED"),
|
|
105
|
+
)
|
|
106
|
+
for t in tasks
|
|
107
|
+
] # fmt: skip
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def format_tasks(tasks: list[Task], show_all: bool = False) -> str:
|
|
111
|
+
"""Render a table of open tasks (or every task with ``show_all``)."""
|
|
112
|
+
rows = [t for t in tasks if show_all or t.state not in TERMINAL]
|
|
113
|
+
if not rows:
|
|
114
|
+
return "No open tasks."
|
|
115
|
+
lines = []
|
|
116
|
+
for t in rows:
|
|
117
|
+
wait = (
|
|
118
|
+
f" BLOCKED by {', '.join(t.blocked_by)}"
|
|
119
|
+
if t.blocked_by and t.state not in TERMINAL
|
|
120
|
+
else ""
|
|
121
|
+
)
|
|
122
|
+
tag = " [archived]" if t.archived else ""
|
|
123
|
+
lines.append(f"{t.id} {t.state:<19}{t.title}{wait}{tag}")
|
|
124
|
+
return "\n".join(lines)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def archive_finished(repo: Path, delete: bool = False) -> list[Task]:
|
|
128
|
+
"""Move finished tasks into ``tasks/archive/`` (or delete them).
|
|
129
|
+
|
|
130
|
+
A task is finished when its state is ACCEPTED, CANCELLED or FAILED. Running it
|
|
131
|
+
twice changes nothing the second time.
|
|
132
|
+
|
|
133
|
+
Returns:
|
|
134
|
+
The tasks that were moved or deleted.
|
|
135
|
+
"""
|
|
136
|
+
root = tasks_dir(repo)
|
|
137
|
+
moved = []
|
|
138
|
+
for p in sorted(root.glob("TASK-*.md")):
|
|
139
|
+
task = parse_task(p)
|
|
140
|
+
if task is None or task.state not in TERMINAL:
|
|
141
|
+
continue
|
|
142
|
+
if delete:
|
|
143
|
+
p.unlink()
|
|
144
|
+
else:
|
|
145
|
+
(root / "archive").mkdir(exist_ok=True)
|
|
146
|
+
dest = root / "archive" / p.name
|
|
147
|
+
shutil.move(str(p), dest)
|
|
148
|
+
os.utime(dest) # a move keeps the old mtime; the archive date is when it was archived
|
|
149
|
+
moved.append(task)
|
|
150
|
+
return moved
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def prune_archive(
|
|
154
|
+
repo: Path, days: int, apply: bool = False, now: float | None = None
|
|
155
|
+
) -> list[Path]:
|
|
156
|
+
"""List archived task files older than ``days``, deleting them when ``apply`` is true.
|
|
157
|
+
|
|
158
|
+
Age is measured from when the file was archived (its modification time).
|
|
159
|
+
|
|
160
|
+
Args:
|
|
161
|
+
repo: Project root.
|
|
162
|
+
days: Keep files archived within this many days.
|
|
163
|
+
apply: Delete the old files instead of only listing them.
|
|
164
|
+
now: Current time as a Unix timestamp (injectable for tests).
|
|
165
|
+
|
|
166
|
+
Returns:
|
|
167
|
+
The old archived files, oldest first. Empty when there is no archive folder.
|
|
168
|
+
"""
|
|
169
|
+
arch = tasks_dir(repo) / "archive"
|
|
170
|
+
if not arch.is_dir():
|
|
171
|
+
return []
|
|
172
|
+
cutoff = (time.time() if now is None else now) - days * 86400
|
|
173
|
+
old = sorted(
|
|
174
|
+
(p for p in arch.glob("TASK-*.md") if p.stat().st_mtime < cutoff),
|
|
175
|
+
key=lambda p: p.stat().st_mtime,
|
|
176
|
+
)
|
|
177
|
+
if apply:
|
|
178
|
+
for p in old:
|
|
179
|
+
p.unlink()
|
|
180
|
+
return old
|
caokit/worktrees.py
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""Per-task git worktrees through CAO's ``use_worktree``.
|
|
2
|
+
|
|
3
|
+
CAO gives one engineer terminal its own ``git worktree`` at ``<repo>/.cao/worktrees/<terminal-id>``
|
|
4
|
+
on branch ``cao/<terminal-id>``, cut from the repo's HEAD. It removes the checkout when the
|
|
5
|
+
terminal is deleted and keeps the branch only if it holds commits. The agents do the dispatching
|
|
6
|
+
(see the role files); this module is what caokit itself knows about it: whether a repo can use
|
|
7
|
+
worktrees, and what CAO has left in one.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import subprocess
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from .adapters import Runner, default_runner
|
|
15
|
+
|
|
16
|
+
BRANCH_PREFIX = (
|
|
17
|
+
"cao/" # CAO's own branch naming; see cli_agent_orchestrator.services.worktree_service
|
|
18
|
+
)
|
|
19
|
+
WORKTREE_SUBDIR = ".cao/worktrees"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class TaskWorktree:
|
|
24
|
+
"""One branch CAO created for an engineer terminal.
|
|
25
|
+
|
|
26
|
+
Attributes:
|
|
27
|
+
branch: ``cao/<terminal-id>``.
|
|
28
|
+
path: The checkout, or None once CAO has removed it (the branch outlives it).
|
|
29
|
+
ahead: Commits on the branch that HEAD does not have (work not yet merged).
|
|
30
|
+
dirty: True if the checkout has uncommitted changes, which CAO discards on teardown.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
branch: str
|
|
34
|
+
path: Path | None
|
|
35
|
+
ahead: int
|
|
36
|
+
dirty: bool
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _git(runner: Runner, repo: Path, *args: str) -> tuple[int, str]:
|
|
40
|
+
try:
|
|
41
|
+
r = runner(["git", "-C", str(repo), *args])
|
|
42
|
+
except (OSError, subprocess.TimeoutExpired) as e:
|
|
43
|
+
return 1, str(e)
|
|
44
|
+
return r.returncode, ((r.stdout or "") if r.returncode == 0 else (r.stderr or "")).strip()
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def problems(repo: Path, runner: Runner = default_runner) -> tuple[list[str], list[str]]:
|
|
48
|
+
"""Check that ``repo`` can give each engineer a worktree.
|
|
49
|
+
|
|
50
|
+
Args:
|
|
51
|
+
repo: Project root.
|
|
52
|
+
runner: Command runner (injectable for tests).
|
|
53
|
+
|
|
54
|
+
Returns:
|
|
55
|
+
``(errors, warnings)``. Errors mean CAO's ``use_worktree`` would fail or lose work;
|
|
56
|
+
warnings are things the human should know first.
|
|
57
|
+
"""
|
|
58
|
+
errors: list[str] = []
|
|
59
|
+
warnings: list[str] = []
|
|
60
|
+
code, out = _git(runner, repo, "rev-parse", "--show-toplevel")
|
|
61
|
+
if code != 0:
|
|
62
|
+
return [f"{repo} is not a git repository, so there is nothing to make worktrees from"], []
|
|
63
|
+
if Path(out).resolve() != Path(repo).resolve():
|
|
64
|
+
errors.append(f"{repo} is inside the repository at {out}; run caokit on its root")
|
|
65
|
+
if _git(runner, repo, "rev-parse", "--verify", "HEAD")[0] != 0:
|
|
66
|
+
errors.append("the repository has no commits yet; a worktree is cut from HEAD")
|
|
67
|
+
code, out = _git(runner, repo, "status", "--porcelain", "--untracked-files=no")
|
|
68
|
+
if code == 0 and out:
|
|
69
|
+
warnings.append(
|
|
70
|
+
"uncommitted changes in the main checkout are not in a new worktree "
|
|
71
|
+
"(it starts from the last commit)"
|
|
72
|
+
)
|
|
73
|
+
return errors, warnings
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def list_task_worktrees(repo: Path, runner: Runner = default_runner) -> list[TaskWorktree]:
|
|
77
|
+
"""Return every ``cao/*`` branch in ``repo`` with its checkout and unmerged work.
|
|
78
|
+
|
|
79
|
+
Args:
|
|
80
|
+
repo: Project root.
|
|
81
|
+
runner: Command runner (injectable for tests).
|
|
82
|
+
|
|
83
|
+
Returns:
|
|
84
|
+
One entry per branch, sorted by branch name. Empty if there are none or git fails.
|
|
85
|
+
"""
|
|
86
|
+
code, out = _git(runner, repo, "worktree", "list", "--porcelain")
|
|
87
|
+
paths: dict[str, Path] = {}
|
|
88
|
+
if code == 0:
|
|
89
|
+
path = None
|
|
90
|
+
for line in out.splitlines():
|
|
91
|
+
if line.startswith("worktree "):
|
|
92
|
+
path = Path(line[len("worktree ") :])
|
|
93
|
+
elif line.startswith("branch refs/heads/") and path is not None:
|
|
94
|
+
paths[line[len("branch refs/heads/") :]] = path
|
|
95
|
+
code, out = _git(runner, repo, "for-each-ref", "--format=%(refname:short)", "refs/heads/cao/")
|
|
96
|
+
if code != 0:
|
|
97
|
+
return []
|
|
98
|
+
found = []
|
|
99
|
+
for branch in sorted(out.split()):
|
|
100
|
+
c, n = _git(runner, repo, "rev-list", "--count", f"HEAD..{branch}")
|
|
101
|
+
path = paths.get(branch)
|
|
102
|
+
dirty = False
|
|
103
|
+
if path is not None:
|
|
104
|
+
_, status = _git(runner, path, "status", "--porcelain")
|
|
105
|
+
dirty = bool(status)
|
|
106
|
+
found.append(TaskWorktree(branch, path, int(n) if c == 0 and n.isdigit() else 0, dirty))
|
|
107
|
+
return found
|