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
@@ -0,0 +1,18 @@
1
+ Execute TASK-0001 from `.company/tasks/TASK-0001-smoke-test.md`.
2
+
3
+ Follow this exact sequence:
4
+
5
+ 1. Read `.company/COMPANY.md`, every file under `.company/contracts/`, and the TASK-0001 file.
6
+ 2. Confirm the only permitted writes are the paths listed in TASK-0001.
7
+ 3. Use CAO `assign` to launch the profile ROSTER.md lists for `engineer` with the Engineering deliverable from TASK-0001. Name the exact artifact path. State that source changes and mutating commands are forbidden.
8
+ 4. Record the returned engineer terminal ID in `.company/TEAM.md`.
9
+ 5. Use CAO `assign` to launch the profile ROSTER.md lists for `scout` with the Scout deliverable from TASK-0001. Name the exact artifact path. State that source changes and external side effects are forbidden.
10
+ 6. Record the returned scout terminal ID in `.company/TEAM.md`.
11
+ 7. Do not perform either worker's assignment yourself. Become idle after dispatch so callbacks can arrive.
12
+ 8. Wait until both workers have sent callbacks. Reuse and message an existing worker if clarification is needed; do not create duplicates.
13
+ 9. Verify both artifact files exist and are non-empty.
14
+ 10. Use CAO `handoff` to the profile ROSTER.md lists for `inspector`. Instruct it to read TASK-0001 and both worker artifacts, then write `.company/reviews/TASK-0001-cross-provider-review.md`.
15
+ 11. Read the review and write `.company/decisions/TASK-0001-general-synthesis.md`.
16
+ 12. Check each acceptance criterion as PASS, FAIL, or NOT VERIFIED with evidence.
17
+ 13. Do not edit application source, run deployment actions, merge, push, or access secrets.
18
+ 14. Finish by reporting only the task state, worker terminal IDs, artifact paths, failed/not-verified checks, and decisions required from the human.
@@ -0,0 +1,109 @@
1
+ # TASK-0001: Role-based orchestration smoke test
2
+
3
+ - **State:** APPROVED
4
+ - **Owner:** `general`
5
+ - **Workers:** `engineer`, `scout`
6
+ - **Reviewer:** `inspector`
7
+
8
+ ## Objective
9
+
10
+ Prove that the General can launch and coordinate one engineer and one scout, collect file-based artifacts from both, obtain an independent review, and write a synthesis.
11
+
12
+ ## Inputs
13
+
14
+ - repository contents,
15
+ - `.company/COMPANY.md`,
16
+ - all `.company/contracts/*.md` files.
17
+
18
+ ## Allowed paths
19
+
20
+ All agents may read the repository according to their profile. Writes are limited to:
21
+
22
+ - `.company/TEAM.md`
23
+ - `.company/BACKLOG.md`
24
+ - `.company/artifacts/TASK-0001-engineering-assessment.md`
25
+ - `.company/research/TASK-0001-agy-product-assessment.md`
26
+ - `.company/reviews/TASK-0001-cross-provider-review.md`
27
+ - `.company/decisions/TASK-0001-general-synthesis.md`
28
+
29
+ ## Forbidden paths and actions
30
+
31
+ - no application-source modifications,
32
+ - no dependency or lockfile changes,
33
+ - no Git state changes,
34
+ - no deployment, merge, push, or publishing,
35
+ - no credentials, secrets, production systems, or external side effects,
36
+ - no worker may spawn another worker.
37
+
38
+ ## Engineering deliverable (`engineer`)
39
+
40
+ Write `.company/artifacts/TASK-0001-engineering-assessment.md` covering:
41
+
42
+ - repository structure and primary components,
43
+ - likely execution path or architecture,
44
+ - test and validation setup,
45
+ - five technical risks or improvement opportunities,
46
+ - three candidate engineering tasks with estimated complexity,
47
+ - explicit unknowns.
48
+
49
+ Do not modify application source and do not run commands that mutate the project.
50
+
51
+ ## Scout deliverable
52
+
53
+ Write `.company/research/TASK-0001-agy-product-assessment.md` covering:
54
+
55
+ - apparent product purpose and target user,
56
+ - observed user journey from repository evidence,
57
+ - missing or ambiguous product capabilities,
58
+ - three possible product directions,
59
+ - questions requiring human validation,
60
+ - sources and evidence for any web-derived statements.
61
+
62
+ Do not modify application source or perform external side effects.
63
+
64
+ ## Review deliverable
65
+
66
+ `inspector` writes `.company/reviews/TASK-0001-cross-provider-review.md` covering:
67
+
68
+ - task-scope compliance,
69
+ - overlap and disagreement between artifacts,
70
+ - unsupported or weakly supported claims,
71
+ - whether each acceptance check passes,
72
+ - blockers before a source-changing prototype task.
73
+
74
+ ## General deliverable
75
+
76
+ Write `.company/decisions/TASK-0001-general-synthesis.md` with:
77
+
78
+ - provider routing proof,
79
+ - worker terminal IDs,
80
+ - completed artifact paths,
81
+ - agreements and disagreements,
82
+ - recommended next milestone,
83
+ - proposed TASK-0002,
84
+ - decisions required from the human.
85
+
86
+ ## Acceptance checks
87
+
88
+ - [ ] The active CAO session shows the General.
89
+ - [ ] The General launches the profile ROSTER.md lists for `engineer`.
90
+ - [ ] The General launches the profile ROSTER.md lists for `scout`.
91
+ - [ ] Both workers return callbacks to the General.
92
+ - [ ] Both required worker artifacts exist and are non-empty.
93
+ - [ ] No application-source file is modified.
94
+ - [ ] `inspector` produces the review artifact.
95
+ - [ ] The General produces the synthesis artifact.
96
+ - [ ] The synthesis records unresolved uncertainty rather than inventing missing evidence.
97
+
98
+ ## Validation
99
+
100
+ ```bash
101
+ git status --short
102
+ find .company -type f -print
103
+ ```
104
+
105
+ Application-source changes cause this task to fail.
106
+
107
+ ## Approval boundary
108
+
109
+ The human must approve any source-changing follow-up task.
@@ -0,0 +1,73 @@
1
+ # <TASK-ID>: <Title>
2
+
3
+ - **State:** PROPOSED
4
+ - **Owner role:** <role, e.g. `engineer`; the General resolves it via ROSTER.md>
5
+ - **Reviewer role:** <role `inspector`; its tool must differ from the owner's for source changes>
6
+ - **Depends on:** <TASK-NNNN, TASK-NNNN, or `none`; the task is blocked until each is ACCEPTED>
7
+ - **Created:** <YYYY-MM-DD>
8
+ - **Approved by human:** <date and how, filled in only after approval>
9
+
10
+ ## Objective
11
+
12
+ <One concrete, observable outcome.>
13
+
14
+ ## Inputs
15
+
16
+ - <file, artifact, decision, or source>
17
+
18
+ ## Allowed paths
19
+
20
+ - <file or directory the worker may modify; prefer files>
21
+
22
+ ## Forbidden paths and actions
23
+
24
+ - <path or action>
25
+
26
+ ## Deliverables
27
+
28
+ - `<exact artifact path>`
29
+ - <source change, when authorized>
30
+
31
+ ## Acceptance checks (human must confirm)
32
+
33
+ - [ ] <observable criterion>
34
+ - [ ] <observable criterion>
35
+
36
+ ## Authorised validation commands
37
+
38
+ The engineer and the reviewer may run exactly these commands, and nothing else: no deletes, no cleanup, no installs, no state-changing git commands. The engineer must run them before reporting `COMPLETED`; the reviewer re-runs them independently. Both paste the real output in a **Commands run** section. Prefer commands that leave no generated files behind (for Python: `python3 -B -m unittest ...`).
39
+
40
+ ```bash
41
+ <command>
42
+ git status --short
43
+ ```
44
+
45
+ ## Team plan
46
+
47
+ Filled in by the captain, with an estimate for every agent including the captain. On the plan path the human approves this section before anything launches. Cost is a rough range of fresh tokens (input + output + cache writes, no cache reads) with a low/med/high label and a few words of basis. Mark the path taken: **Path:** FAST | PLAN
48
+
49
+ | Sub-task | Depends on | Agent (profile/role) | Why this agent | Parallel with | Est. cost |
50
+ |---|---|---|---|---|---|
51
+ | <sub-task> | <ids or NONE> | <agent> | <reason> | <ids or NONE> | <tokens or low/med/high> |
52
+
53
+ - **Total agents:** <n> (fast path: at most 2)
54
+ - **Plan-path triggers hit:** <auth/secrets | data | dependency | external | ambiguity | >2 agents | NONE>
55
+ - **Clarification rounds so far:** 0
56
+
57
+ ## Approval boundary
58
+
59
+ The human must approve before:
60
+
61
+ - <action>
62
+
63
+ ## Dependencies
64
+
65
+ - <task or decision, or NONE>
66
+
67
+ ## Open questions
68
+
69
+ - <anything the General could not determine from the repository, or NONE>
70
+
71
+ ## Callback format
72
+
73
+ Use the standard callback in `.company/contracts/COMMUNICATION_CONTRACT.md`.
caokit/doctor.py ADDED
@@ -0,0 +1,287 @@
1
+ """`caokit doctor`: what is installed, authenticated and consistent."""
2
+
3
+ import os
4
+ import re
5
+ import shutil
6
+ from collections.abc import Callable, Sequence
7
+ from dataclasses import dataclass, field
8
+ from pathlib import Path
9
+
10
+ from .adapters import Runner, adapter_ids, default_runner, load_adapter
11
+ from .config import Config, ConfigError, default_path, load_config
12
+ from .roster import check_roster
13
+
14
+ # PyPI/uv name of CAO (not the `cao` binary name).
15
+ CAO_PKG = "cli-agent-orchestrator"
16
+
17
+ # Looks up an executable on PATH; returns its path or None (like shutil.which).
18
+ Which = Callable[[str], str | None]
19
+
20
+
21
+ @dataclass
22
+ class Check:
23
+ """One line of the doctor report."""
24
+
25
+ level: str # "ok" | "warn" | "fail"
26
+ text: str
27
+
28
+
29
+ @dataclass
30
+ class Report:
31
+ """Ordered list of checks produced by :func:`run_doctor`."""
32
+
33
+ checks: list[Check] = field(default_factory=list)
34
+
35
+ def add(self, level: str, text: str) -> None:
36
+ """Append a check.
37
+
38
+ Args:
39
+ level: One of ``"ok"``, ``"warn"`` or ``"fail"``.
40
+ text: Human-readable description of the finding.
41
+ """
42
+ self.checks.append(Check(level, text))
43
+
44
+ @property
45
+ def failed(self) -> bool:
46
+ """Whether any check has level ``"fail"`` (drives the exit code)."""
47
+ return any(c.level == "fail" for c in self.checks)
48
+
49
+
50
+ def _run(runner: Runner, argv: Sequence[str]) -> str | None:
51
+ """Run a command and return its combined output, or None if it did not succeed.
52
+
53
+ Args:
54
+ runner: Command runner (injected so tests need no real CLIs).
55
+ argv: Command and arguments.
56
+
57
+ Returns:
58
+ Stripped stdout+stderr on exit code 0; None on a non-zero exit or when
59
+ the command could not be run at all.
60
+ """
61
+ try:
62
+ r = runner(argv)
63
+ except Exception: # noqa: BLE001 - missing binary, timeout, etc. all mean "unavailable"
64
+ return None
65
+ return ((r.stdout or "") + (r.stderr or "")).strip() if r.returncode == 0 else None
66
+
67
+
68
+ def _cao_installs(runner: Runner, which: Which) -> list[str]:
69
+ """Find CAO installs other than the uv tool (pipx, or pip in a PATH python).
70
+
71
+ Args:
72
+ runner: Command runner.
73
+ which: PATH lookup function.
74
+
75
+ Returns:
76
+ Labels of the extra installs; empty when there are none.
77
+ """
78
+ extra: list[str] = []
79
+ if which("pipx"):
80
+ out = _run(runner, ["pipx", "list", "--short"])
81
+ if out and re.search(rf"(?m)^{CAO_PKG} ", out):
82
+ extra.append("pipx")
83
+ seen: set[str] = set()
84
+ for name in ("python3", "python3.9", "python3.10", "python3.11", "python3.12", "python3.13"):
85
+ p = which(name)
86
+ if not p:
87
+ continue
88
+ # Resolve symlinks so python3 and python3.12 are not probed twice.
89
+ real = os.path.realpath(p)
90
+ if real in seen:
91
+ continue
92
+ seen.add(real)
93
+ code = f"import importlib.metadata as m; print(m.version('{CAO_PKG}'))"
94
+ if _run(runner, [real, "-c", code]):
95
+ extra.append(f"pip in {real}")
96
+ return extra
97
+
98
+
99
+ def _check_cao(rep: Report, expected: str, runner: Runner, which: Which) -> None:
100
+ """Add checks for the cao binary: presence, exact version, uv origin, single install.
101
+
102
+ Args:
103
+ rep: Report to append to.
104
+ expected: CAO version this kit is validated against.
105
+ runner: Command runner.
106
+ which: PATH lookup function.
107
+ """
108
+ if not which("cao"):
109
+ rep.add(
110
+ "fail",
111
+ f"cao not found on PATH (uv tool install 'cli-agent-orchestrator=={expected}')",
112
+ )
113
+ return
114
+ v = _run(runner, ["cao", "--version"]) or ""
115
+ m = re.search(r"(\d+\.\d+\.\d+)", v)
116
+ if not m:
117
+ rep.add("fail", "cannot read `cao --version`")
118
+ elif m.group(1) != expected:
119
+ # A different version is a failure, not a warning: the kit is only validated on one.
120
+ rep.add(
121
+ "fail",
122
+ f"cao {m.group(1)} found, expected {expected} "
123
+ "(change only with `uv tool install --force 'cli-agent-orchestrator==X'`)",
124
+ )
125
+ else:
126
+ rep.add("ok", f"cao {m.group(1)}")
127
+ if which("uv"):
128
+ out = _run(runner, ["uv", "tool", "list"]) or ""
129
+ if re.search(rf"(?m)^{CAO_PKG} ", out):
130
+ rep.add("ok", "cao installed as a uv tool")
131
+ else:
132
+ rep.add("warn", "cao is not a uv tool install")
133
+ extra = _cao_installs(runner, which)
134
+ if extra:
135
+ joined = ", ".join(extra)
136
+ rep.add("fail", f"additional cao installs found: {joined} (keep exactly one, the uv tool)")
137
+ else:
138
+ rep.add("ok", "single cao install")
139
+
140
+
141
+ def _check_server(rep: Report, runner: Runner, which: Which) -> None:
142
+ """Add checks that cao-server answers and serves its web UI.
143
+
144
+ A reachable server that returns 404 for ``/`` is usually an old process started from a
145
+ previous CAO install, which a restart fixes. Not running is only a warning, since the
146
+ server starts on demand.
147
+
148
+ Args:
149
+ rep: Report to append to.
150
+ runner: Command runner.
151
+ which: PATH lookup function.
152
+ """
153
+ if not which("curl"):
154
+ return
155
+ base = "http://{}:{}".format(
156
+ os.environ.get("CAO_API_HOST", "127.0.0.1"), os.environ.get("CAO_API_PORT", "9889")
157
+ )
158
+ health = _run(runner, ["curl", "-s", "-m", "3", base + "/health"])
159
+ if not health:
160
+ rep.add("warn", "cao-server is not running (start it with `caokit server`)")
161
+ return
162
+ rep.add("ok", "cao-server is running")
163
+ code = _run(
164
+ runner, ["curl", "-s", "-m", "3", "-o", "/dev/null", "-w", "%{http_code}", base + "/"]
165
+ )
166
+ if code == "200":
167
+ rep.add("ok", "cao-server serves its web UI")
168
+ else:
169
+ rep.add(
170
+ "warn",
171
+ f"cao-server web UI answered {code or 'nothing'} at /: restart the server "
172
+ "(`caokit stop` your sessions, stop the old cao-server, run `caokit server`)",
173
+ )
174
+
175
+
176
+ def _check_tools(rep: Report, used: set[str], runner: Runner, which: Which) -> None:
177
+ """Add checks for every adapter's CLI: binary, version and auth.
178
+
179
+ A missing or unauthenticated tool is a failure only if the roster uses it.
180
+
181
+ Args:
182
+ rep: Report to append to.
183
+ used: Tool IDs referenced by the configured roster.
184
+ runner: Command runner.
185
+ which: PATH lookup function.
186
+ """
187
+ ready = 0
188
+ for tid in adapter_ids():
189
+ a = load_adapter(tid)
190
+ if a.status != "active":
191
+ rep.add("ok", f"{tid}: parked (not selectable)")
192
+ continue
193
+ # Not having a tool you do not use is fine; not having one you rely on is not.
194
+ lvl_missing = "fail" if tid in used else "warn"
195
+ if not which(a.binary):
196
+ rep.add(lvl_missing, f"{tid}: `{a.binary}` not found on PATH")
197
+ continue
198
+ ver = _run(runner, a.version_cmd)
199
+ if ver is None:
200
+ rep.add(lvl_missing, f"{tid}: `{' '.join(a.version_cmd)}` failed")
201
+ continue
202
+ first_line = ver.splitlines()[0] if ver else "installed"
203
+ line = f"{tid}: {first_line} ({a.enforcement} enforcement)"
204
+ if a.auth_cmd:
205
+ if _run(runner, a.auth_cmd) is None:
206
+ rep.add(
207
+ lvl_missing,
208
+ f"{tid}: installed but auth check `{' '.join(a.auth_cmd)}` failed (logged in?)",
209
+ )
210
+ continue
211
+ else:
212
+ line += "; auth not checked" # e.g. claude has no cheap non-interactive check
213
+ rep.add("ok", line)
214
+ ready += 1
215
+ if ready < 2:
216
+ rep.add(
217
+ "warn", f"only {ready} provider(s) ready: independent cross-provider review needs two"
218
+ )
219
+
220
+
221
+ def _check_roster(rep: Report, cfg: Config, path: Path) -> None:
222
+ """Add the roster guardrail results for a loaded config.
223
+
224
+ Args:
225
+ rep: Report to append to.
226
+ cfg: Loaded configuration.
227
+ path: Where the config was loaded from (shown on success).
228
+ """
229
+ r = check_roster(cfg)
230
+ for err in r.errors:
231
+ rep.add("fail", f"roster: {err}")
232
+ for warning in r.warnings:
233
+ rep.add("warn", f"roster: {warning}")
234
+ if not r.errors:
235
+ rep.add("ok", f"roster valid ({path})")
236
+
237
+
238
+ def run_doctor(
239
+ config_path: Path | None = None,
240
+ runner: Runner = default_runner,
241
+ which: Which = shutil.which,
242
+ ) -> Report:
243
+ """Inspect the machine: CAO, provider CLIs, supporting tools and the roster.
244
+
245
+ Args:
246
+ config_path: Path to ``caokit.toml``; the default location if None.
247
+ runner: Command runner (injectable for tests).
248
+ which: PATH lookup (injectable for tests).
249
+
250
+ Returns:
251
+ A :class:`Report`; ``report.failed`` is true if anything must be fixed.
252
+ """
253
+ rep = Report()
254
+ cfg: Config | None = None
255
+ path = Path(config_path) if config_path else default_path()
256
+ try:
257
+ cfg = load_config(path)
258
+ except ConfigError as e:
259
+ # A missing config is expected before `company create`, so only warn.
260
+ rep.add("warn", str(e))
261
+ used = {b.tool for b in cfg.roles.values()} if cfg else set()
262
+ expected = cfg.expected_cao_version if cfg else "2.5.4"
263
+
264
+ _check_cao(rep, expected, runner, which)
265
+ for binary in ("tmux", "git", "uv"):
266
+ found = which(binary)
267
+ # uv is only needed to install/upgrade; tmux and git are required to run at all.
268
+ level = "ok" if found else ("warn" if binary == "uv" else "fail")
269
+ rep.add(level, f"{binary} {'found' if found else 'not found'}")
270
+ _check_server(rep, runner, which)
271
+ _check_tools(rep, used, runner, which)
272
+ if cfg:
273
+ _check_roster(rep, cfg, path)
274
+ return rep
275
+
276
+
277
+ def format_report(rep: Report) -> str:
278
+ """Render a report as one tagged line per check.
279
+
280
+ Args:
281
+ rep: Report to render.
282
+
283
+ Returns:
284
+ Text such as ``[ ok ] cao 2.5.4`` joined by newlines.
285
+ """
286
+ tag = {"ok": "[ ok ]", "warn": "[warn]", "fail": "[FAIL]"}
287
+ return "\n".join(f"{tag[c.level]} {c.text}" for c in rep.checks)
caokit/gitignore.py ADDED
@@ -0,0 +1,36 @@
1
+ """Merge the caokit-managed block into a project .gitignore."""
2
+
3
+ BEGIN = "# >>> caokit >>>"
4
+ END = "# <<< caokit <<<"
5
+ # Ignore everything under .company/ except contracts/, which the team tracks.
6
+ RULES = [".company/*", "!.company/contracts/"]
7
+
8
+
9
+ def merge_gitignore(existing: str) -> str:
10
+ """Return ``existing`` with the caokit block added or replaced in place.
11
+
12
+ Existing content, ordering and line endings are preserved, and the result
13
+ is idempotent: merging twice changes nothing.
14
+
15
+ Args:
16
+ existing: Current .gitignore text (may be empty).
17
+
18
+ Returns:
19
+ The .gitignore text containing exactly one caokit block.
20
+ """
21
+ # Match the file's own line ending so a CRLF file does not end up mixed.
22
+ nl = "\r\n" if "\r\n" in existing else "\n"
23
+ block = nl.join([BEGIN, *RULES, END])
24
+ lines = existing.split(nl)
25
+ try:
26
+ start = next(i for i, line in enumerate(lines) if line.strip() == BEGIN)
27
+ end = next(i for i, line in enumerate(lines) if i > start and line.strip() == END)
28
+ except StopIteration:
29
+ start = end = -1 # no complete block found: append a new one
30
+ if start >= 0:
31
+ # Replace our previous block (possibly stale rules) where it stands.
32
+ lines[start : end + 1] = block.split(nl)
33
+ return nl.join(lines)
34
+ if existing and not existing.endswith(nl):
35
+ existing += nl # file lacked a trailing newline; do not glue onto the last rule
36
+ return existing + block + nl