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.
Files changed (46) hide show
  1. caokit/__init__.py +3 -0
  2. caokit/adapters.py +259 -0
  3. caokit/cli.py +704 -0
  4. caokit/company.py +115 -0
  5. caokit/config.py +191 -0
  6. caokit/data/adapters/antigravity.toml +35 -0
  7. caokit/data/adapters/claude.toml +38 -0
  8. caokit/data/adapters/codex.toml +33 -0
  9. caokit/data/roles/engineer.md +90 -0
  10. caokit/data/roles/general.md +177 -0
  11. caokit/data/roles/inspector.md +55 -0
  12. caokit/data/roles/scout.md +77 -0
  13. caokit/data/scaffold/company/BACKLOG.md +5 -0
  14. caokit/data/scaffold/company/COMPANY.md +28 -0
  15. caokit/data/scaffold/company/DECISIONS.md +5 -0
  16. caokit/data/scaffold/company/README.md +16 -0
  17. caokit/data/scaffold/company/TEAM.md +7 -0
  18. caokit/data/scaffold/company/contracts/ARTIFACT_CONTRACT.md +44 -0
  19. caokit/data/scaffold/company/contracts/COMMUNICATION_CONTRACT.md +38 -0
  20. caokit/data/scaffold/company/contracts/COMPANY_CONTRACT.md +42 -0
  21. caokit/data/scaffold/company/contracts/REVIEW_CONTRACT.md +36 -0
  22. caokit/data/scaffold/company/contracts/SAFETY_CONTRACT.md +40 -0
  23. caokit/data/scaffold/company/contracts/STATE_CONTRACT.md +31 -0
  24. caokit/data/scaffold/company/contracts/TASK_CONTRACT.md +26 -0
  25. caokit/data/scaffold/company/prompts/first-run.md +18 -0
  26. caokit/data/scaffold/company/tasks/TASK-0001-smoke-test.md +109 -0
  27. caokit/data/scaffold/company/tasks/TASK_TEMPLATE.md +73 -0
  28. caokit/doctor.py +287 -0
  29. caokit/gitignore.py +36 -0
  30. caokit/init_project.py +183 -0
  31. caokit/install.py +242 -0
  32. caokit/launch.py +102 -0
  33. caokit/project.py +359 -0
  34. caokit/render.py +129 -0
  35. caokit/reset.py +208 -0
  36. caokit/roster.py +95 -0
  37. caokit/schedule.py +104 -0
  38. caokit/stack.py +117 -0
  39. caokit/tasks.py +180 -0
  40. caokit/worktrees.py +107 -0
  41. caokit-0.3.0.dev0.dist-info/METADATA +382 -0
  42. caokit-0.3.0.dev0.dist-info/RECORD +46 -0
  43. caokit-0.3.0.dev0.dist-info/WHEEL +5 -0
  44. caokit-0.3.0.dev0.dist-info/entry_points.txt +2 -0
  45. caokit-0.3.0.dev0.dist-info/licenses/LICENSE +21 -0
  46. 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