oc_orchestrator
Loading...
Searching...
No Matches
plan_manage.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# -*- coding: utf-8 -*-
3# oc_orchestrator/plan_manage.py
4
5# OCT-LINT: disable=tests_exist reason="Covered by tests/test_plan_manage_cli.py, tests/test_241_worktree_env.py, tests/test_243_worktree_extras.py and oc_chat's test_plan_manage_manifest.py"
6"""
7CLI for direct plan-inventory operations (Phase J).
8
9Purpose
10-------
11Lets humans query and heal the plan inventory without needing OC Chat
12to be running. Subcommands:
13
14 list Print every record (or filter by --status).
15 status PLAN Print one record's full metadata.
16 reconcile Heal inventory/filesystem drift.
17 complete PLAN Mark a plan complete and rename file to ``*_complete.md``.
18 archive Move ``*_complete.md`` files to ``plans/old/``.
19 prepare-worktree NNN
20 Create a plan's branch, worktree and its own uv venv;
21 ``--smoke`` also runs the worker's first two commands
22 inside it (plan 243 P4).
23 teardown-worktree NNN
24 Remove that worktree and its venv (plan 241 P5),
25 resolving the plan from ``plans/`` and then from
26 ``plans/completed/`` (plan 243 P5).
27
28Responsibilities
29----------------
30- Thin wrapper around :mod:`oc_orchestrator.plan_inventory` and
31 :mod:`oc_orchestrator.plans`. No new business logic.
32- Exit code 0 on success, 1 on usage error or unrecoverable failure.
33- :func:`main` carries the two consumer seams (plan 270 P4): ``prog``,
34 the name the CLI gives itself in ``--help``, and ``dispatch_hooks``,
35 the factory that decides who is told about a dispatch. Both default
36 to this package standing alone.
37
38Diagnostics
39-----------
40Domain: OC_CHAT.MAIN
41Levels:
42 L2 — CLI lifecycle events
43 L3 — per-subcommand semantic details
44 L4 — per-record traces
45
46Contracts
47---------
48- Stable interface: option names won't change without a deprecation
49 cycle (matches the project convention for ``migrate_session_names``).
50- ``--inventory`` and ``--plans-dir`` overrides are provided for tests
51 and for power users with non-default project layouts.
52
53Dependencies
54------------
55- oc_diagnostics (structured debug logging via _dbg)
56- oc_orchestrator.plan_inventory (the JSON inventory, PlanPaths, the status vocabulary and reconcile)
57- oc_orchestrator.plans (the default plans directory and the list, find and complete operations)
58- option_c_agent_contracts (lazy; PlanManifest from the ``[manifests]`` extra, imported inside :func:`_load_contracts` only: every other sub-command runs without it)
59- oc_orchestrator.plan_metadata (PlanMetadataProvider and resolve_provider - the plan-header reader for manifest derivation)
60- oc_orchestrator.agent_instances (lazy; agent id validation and the instance registry for the attested model, the agents view and dispatch)
61- oc_orchestrator.dispatch (lazy; plan anchor resolution for the worktree sub-commands, and the dispatch workflow)
62- oc_orchestrator.worktree_env (lazy; worktree prepare and teardown)
63- option_c_agent_contracts.canonical (lazy; canonical_json for the manifest bytes, from the ``[manifests]`` extra)
64- oc_orchestrator.plan_status (not imported; loaded by name through importlib for the worktree status file and the plan number)
65"""
66from __future__ import annotations
67
68import argparse
69import hashlib
70import importlib
71import re
72import sys
73import tempfile
74from collections.abc import Callable, Mapping
75from pathlib import Path
76from typing import Any
77
78from oc_diagnostics import _dbg
79
80from oc_orchestrator.plan_metadata import PlanMetadataProvider, resolve_provider
82 JsonPlanInventory,
83 PlanInventory,
84 PlanPaths,
85 VALID_STATUSES,
86 reconcile,
87)
88from oc_orchestrator.plans import (
89 DEFAULT_PLANS_DIR,
90 complete_plan,
91 find_plan_by_id,
92 list_plans,
93)
94
95
96def _build_inventory(args: argparse.Namespace) -> tuple[PlanInventory, Path]:
97 """Construct an inventory instance per CLI args."""
98 plans_dir = Path(args.plans_dir) if args.plans_dir else DEFAULT_PLANS_DIR
99 inv_path = (
100 Path(args.inventory) if args.inventory
101 else plans_dir / ".inventory.json"
102 )
103 return JsonPlanInventory(inv_path, plans_dir=plans_dir), plans_dir
104
105
106# ── M1.4: PlanManifest commands ────────────────────────────────────────────
107#
108# ``init-manifest`` derives a :class:`option_c_agent_contracts.PlanManifest`
109# from a plan markdown file's header + queue state and writes it as canonical
110# JSON to ``oc_chat/plans/manifests/<plan_id>.manifest.json``. ``validate-
111# manifest`` re-loads that file, Pydantic-validates it, and compares it
112# field-by-field against the live-derived manifest. See plan 093 (M1.4).
113
114MANIFESTS_DIRNAME = "manifests"
115MANIFEST_SUFFIX = ".manifest.json"
116
117# Only numbered plan files participate; the standing templates and the queue
118# file are excluded (plan 093 §3.1).
119_PLAN_FILE_RE = re.compile(r"^\d{3}-.*\.md$")
120_EXCLUDED_PLAN_FILES = frozenset(
121 {"PLAN_TEMPLATE.md", "BUMP_ROADMAP_TEMPLATE.md", "agent_queue.md"}
122)
123
124# The ``**Branch:**`` / ``**Worktree:**`` parsers and the frontmatter path
125# moved to plan_metadata.py (plan 257 P2); this module reads them back
126# through the shared provider. ``**Suggested model:**`` stays here: it is
127# not one of PlanMeta's four fields.
128_MODEL_RE = re.compile(
129 # OCT-LINT: disable=no_hardcoded_secrets reason="markdown-header parsing regexes (**Suggested model:** etc.) flag as high-entropy; this CLI carries no secrets"
130 r"\*\*Suggested model:\*\*\s*`?(?P<value>[A-Za-z0-9._-]+)", re.MULTILINE
131)
132
133_VALID_TIER = frozenset({"frontier", "strong", "fast"})
134_VALID_THINKING = frozenset({"off", "low", "medium", "high", "max"})
135_VALID_SESSION = frozenset({"fresh", "continue", "either"})
136
137
138def _load_contracts() -> tuple[type, Any]:
139 """Import ``PlanManifest`` + ``canonical_json`` from the M1.2 package.
140
141 ``option_c_agent_contracts`` is installed editable as a monorepo sibling
142 (Plan 151 T7/B1 promoted the contracts packages to ``contracts/``), so a
143 plain import resolves the real package directly. The flat-layout sys.path
144 bootstrap was retired when the shadowing project dir left the subproject
145 root.
146 """
147 from option_c_agent_contracts import PlanManifest # type: ignore
148 from option_c_agent_contracts.canonical import canonical_json # type: ignore
149
150 return PlanManifest, canonical_json
151
152
153def _manifests_dir(plans_dir: Path) -> Path:
154 return plans_dir / MANIFESTS_DIRNAME
155
156
157def _manifest_path(plans_dir: Path, plan_id: str) -> Path:
158 return _manifests_dir(plans_dir) / f"{plan_id}{MANIFEST_SUFFIX}"
159
160
161def _derive_assigned_agents(yaml_data: dict[str, object]) -> list[str]:
162 """Derive ``PlanManifest.assigned_agents`` from plan frontmatter.
163
164 Plan 222 ruling 2 (design §4.6 — refuse, never default): an ``agent:``
165 value is either a registry-valid ``agent-NNN`` or a loud refusal naming
166 the input. The legacy A-D letter mapping is GONE: a letter no longer
167 resolves to ``agent_c`` or to anything else, because substituting an
168 identity the operator did not attest is the exact failure §4.6 exists
169 to prevent. An absent ``agent:`` key yields the empty list — a plan
170 that has not been dispatched has no assigned instance, which asserts
171 nothing false about who worked it.
172 """
173 func = "_derive_assigned_agents"
174 from oc_orchestrator.agent_instances import validate_agent_id
175
176 agent_identity = str(yaml_data.get("agent") or "").strip()
177 if not agent_identity:
178 _dbg("OC_CHAT.MAIN", func, "no agent in frontmatter; assigned_agents=[]", 3)
179 return []
180
181 # Raises ValueError naming the offending input (legacy letters included).
182 validate_agent_id(agent_identity)
183 _dbg("OC_CHAT.MAIN", func, f"assigned_agents=[{agent_identity}]", 3)
184 return [agent_identity]
185
186
187def _derive_requires(yaml_data: dict[str, object], plan_id: str) -> dict[str, str]:
188 """Derive ``PlanManifest.requires`` from the frontmatter ``requires:`` block.
189
190 Plan 222 ruling 3: the block is the authority and is mandatory — every
191 active plan has carried one since 2026-08-13. A plan without it is a
192 named refusal, consistent with dispatch, rather than a silent default.
193
194 Only ``tier`` / ``thinking`` / ``session`` are lifted; sibling keys such
195 as ``repo:`` belong to dispatch, and ``PlanRequirements`` is
196 ``extra="forbid"``.
197 """
198 func = "_derive_requires"
199 raw = yaml_data.get("requires")
200 if not isinstance(raw, dict):
201 raise ValueError(
202 f"plan {plan_id} has no 'requires:' frontmatter block "
203 "(expected tier/thinking/session); refusing to default it"
204 )
205
206 resolved: dict[str, str] = {}
207 for key, valid in (
208 ("tier", _VALID_TIER),
209 ("thinking", _VALID_THINKING),
210 ("session", _VALID_SESSION),
211 ):
212 supplied = raw.get(key)
213 value = str(supplied or "").strip().lower()
214 if value not in valid:
215 raise ValueError(
216 f"plan {plan_id} has invalid requires.{key} {supplied!r}; "
217 f"expected one of {sorted(valid)}"
218 )
219 resolved[key] = value
220
221 _dbg("OC_CHAT.MAIN", func, f"requires={resolved}", 3)
222 return resolved
223
224
226 text: str,
227 plan_id: str,
228 *,
229 attested_model: str | None = None,
230 provider: PlanMetadataProvider | None = None,
231) -> dict[str, Any]:
232 """Derive header fields from a plan file's text.
233
234 Supports YAML frontmatter (preferred — ``branch``/``worktree``/``agent``/
235 ``requires`` keys) and the ``**Branch:**`` / ``**Worktree:**`` markdown
236 form, with deterministic branch/worktree derivations so every numbered
237 plan yields a structurally valid manifest. Reads only *text* (hermetic —
238 honours a ``--plans-dir`` override), except for *attested_model*, which
239 the caller resolves.
240
241 Plan 223 P2b — the ``suggested_*`` re-keying. These three manifest fields
242 used to read TOP-LEVEL ``model:`` / ``thinking:`` / ``session:``
243 frontmatter keys that no modern plan carries, so every modern manifest
244 came out internally inconsistent: ``suggested_thinking="medium"`` (the
245 fallback) sitting beside ``requires.thinking="high"`` (the plan's actual
246 declaration). ``thinking`` and ``session`` now read the ``requires:``
247 block, which plan 222 made the mandatory authority, so the two halves of
248 the manifest can no longer disagree. ``suggested_model`` has no
249 plan-level source at all by design (D1: a plan declares a TIER, and the
250 model is the operator's attestation at dispatch), so it takes the
251 dispatch-attested value when the caller supplies one and stays
252 ``"unknown"`` otherwise — never a guess derived from the tier.
253 Legacy ``model:`` / ``**Suggested model:**`` headers are still read for
254 historical plans that carry them.
255 """
256 meta = resolve_provider(provider).metadata_from_text(text)
257 if meta.frontmatter_error is not None:
258 # Re-raised rather than handled: a plan whose frontmatter is not
259 # strict YAML has always surfaced here as a named derivation
260 # failure, and the shared provider returns the error instead of
261 # raising so that plan_status, which never parsed YAML at all,
262 # keeps working on the same file.
263 raise meta.frontmatter_error
264 yaml_data: Mapping[str, object] = meta.frontmatter
265
266 def _md(rx: re.Pattern[str]) -> str | None:
267 match = rx.search(text)
268 if not match:
269 return None
270 return match.group("value").strip().strip("`").strip()
271
272 branch = meta.branch
273 if not branch:
274 stem = re.sub(r"^\d{3}-", "", plan_id)
275 branch = f"feat/{stem}"
276
277 worktree = meta.worktree
278 if not worktree:
279 slug = branch.split("/", 1)[-1].replace("-", "_")
280 worktree = f"G:\\PythonApps\\OC_Chat_{slug}"
281
282 assigned_agents = _derive_assigned_agents(yaml_data)
283 requires = _derive_requires(yaml_data, plan_id)
284
285 model = (
286 str(attested_model or "").strip()
287 or str(yaml_data.get("model") or _md(_MODEL_RE) or "").strip()
288 or "unknown"
289 )
290
291 return {
292 "branch": branch,
293 "worktree": worktree,
294 "assigned_agents": assigned_agents,
295 "requires": requires,
296 "suggested_model": model,
297 "suggested_thinking": requires["thinking"],
298 "suggested_session": requires["session"],
299 }
300
301
302def _derive_status(worktree: str) -> str:
303 """Map the worktree's ``.PLAN_STATUS.md`` to a manifest status.
304
305 Default ``pending`` (the registry spelling of "queued", per ADR-00Z)
306 when no worktree or no status file exists.
307 """
308 plan_status = importlib.import_module("oc_orchestrator.plan_status")
309 try:
310 status_path = Path(worktree) / plan_status.DEFAULT_FILENAME
311 if status_path.is_file():
312 status_obj, _body = plan_status.read(status_path)
313 return plan_status._registry_status(status_obj.status)
314 except Exception: # noqa: BLE001 - status derivation is best-effort
315 pass
316 return "pending"
317
318
319def _attested_model(plan_id: str, agent_registry: str | None) -> str | None:
320 """Return the model attested for *plan_id* at its latest dispatch.
321
322 Plan 223 P2b. The runtime instance registry is the only place the
323 operator's actual assignment is recorded, and reading it is OPT-IN via
324 an explicit path so manifest derivation stays hermetic by default:
325 ``validate-manifest`` compares an on-disk manifest against a live
326 re-derivation, and a derivation that silently read mutable runtime state
327 would report drift every time an instance changed state.
328
329 Best-effort by design — an unreadable or absent registry yields ``None``
330 and the manifest records ``"unknown"``, which is the truthful answer for
331 a plan that has never been dispatched.
332 """
333 func = "_attested_model"
334 if not agent_registry:
335 return None
336 try:
337 from oc_orchestrator.agent_instances import AgentInstanceRegistry
338
339 instances = [
340 instance
341 for instance in AgentInstanceRegistry(Path(agent_registry)).list()
342 if instance.plan_id == plan_id
343 ]
344 except Exception: # noqa: BLE001 - registry read is best-effort
345 _dbg("OC_CHAT.MAIN", func, f"{plan_id}: registry unreadable", 3)
346 return None
347 if not instances:
348 return None
349 # ``list()`` is ordered by agent id, so the last entry is the most recent
350 # instance to have served this plan (re-dispatches append).
351 latest = instances[-1].model_attested.strip()
352 _dbg("OC_CHAT.MAIN", func, f"{plan_id}: attested {latest!r}", 3)
353 return latest or None
354
355
357 plan_id: str,
358 plans_dir: Path,
359 *,
360 agent_registry: str | None = None,
361 paths: PlanPaths | None = None,
362) -> Any:
363 """Build a ``PlanManifest`` for *plan_id* from live plan + instance state.
364
365 ``queue_position`` is always ``None``: the Markdown queue that supplied
366 it retired with the queue machinery (plan 223 P3). The registry's own
367 ``plans.queue_position`` column is untouched and still owns registry-side
368 ordering; what died is this module's derivation of it from a Markdown
369 file that no longer exists.
370
371 *plans_dir* says where to READ the plan from and honours a
372 ``--plans-dir`` override; *paths* says what the plan's body path IS,
373 which is a stable relative identity recorded in the manifest and must
374 not move when a test or a power user reads from somewhere else. They
375 are two different questions and plan 270 P3 keeps them apart.
376 """
377 paths = paths or PlanPaths()
378 plan_status = importlib.import_module("oc_orchestrator.plan_status")
379 PlanManifest, _ = _load_contracts()
380
381 plan_path = plans_dir / f"{plan_id}.md"
382 if not plan_path.is_file():
383 raise FileNotFoundError(f"plan file not found: {plan_path}")
384
385 raw = plan_path.read_bytes()
386 body_sha256 = hashlib.sha256(raw).hexdigest()
387 header = _parse_plan_header(
388 raw.decode("utf-8"),
389 plan_id,
390 attested_model=_attested_model(plan_id, agent_registry),
391 )
392
393 return PlanManifest(
394 plan_id=plan_id,
395 plan_number=plan_status._plan_number(plan_id),
396 branch=header["branch"],
397 worktree=header["worktree"],
398 assigned_agents=header["assigned_agents"],
399 requires=header["requires"],
400 suggested_model=header["suggested_model"],
401 suggested_thinking=header["suggested_thinking"],
402 suggested_session=header["suggested_session"],
403 status=_derive_status(header["worktree"]),
404 # Plan 270 P3: read off PlanPaths rather than a hard-coded
405 # consumer layout; the default is byte-identical to the literal
406 # ``Path("oc_chat") / "plans" / f"{plan_id}.md"`` it replaces.
407 body_path=paths.plan_body_relpath(plan_id).as_posix(),
408 body_sha256=body_sha256,
409 queue_position=None,
410 )
411
412
413def _serialize_manifest(manifest: Any) -> str:
414 """Canonical-JSON the manifest (sorted keys, no whitespace; ADR-00X §3.4)."""
415 _, canonical_json = _load_contracts()
416 return canonical_json(manifest.model_dump(mode="json"))
417
418
419def _atomic_write(path: Path, content: str) -> None:
420 """Write *content* to *path* via a temp file + rename (atomic)."""
421 path.parent.mkdir(parents=True, exist_ok=True)
422 with tempfile.NamedTemporaryFile(
423 mode="w",
424 encoding="utf-8",
425 dir=path.parent,
426 prefix=".manifest_",
427 suffix=".tmp",
428 delete=False,
429 ) as tmp:
430 tmp.write(content)
431 tmp_path = Path(tmp.name)
432 tmp_path.replace(path)
433
434
435def _resolve_target_plan_ids(args: argparse.Namespace, plans_dir: Path) -> list[str]:
436 """Return the plan-id stems a manifest command should act on."""
437 if getattr(args, "all", False):
438 ids: list[str] = []
439 for path in sorted(plans_dir.glob("*.md")):
440 if path.name in _EXCLUDED_PLAN_FILES:
441 continue
442 if not _PLAN_FILE_RE.match(path.name):
443 continue
444 ids.append(path.stem)
445 return ids
446 if getattr(args, "plan", None):
447 return [args.plan]
448 return []
449
450
451def _cmd_init_manifest(args: argparse.Namespace) -> int:
452 func = "plan_manage._cmd_init_manifest"
453 _, plans_dir = _build_inventory(args)
454 if not args.all and not args.plan:
455 print("error: must specify --plan PLAN_ID or --all", file=sys.stderr)
456 return 1
457 plan_ids = _resolve_target_plan_ids(args, plans_dir)
458 if not plan_ids:
459 print("(no matching plan files)")
460 return 0
461
462 exit_code = 0
463 written = 0
464 for plan_id in plan_ids:
465 try:
466 manifest = _derive_manifest(
467 plan_id, plans_dir, agent_registry=args.agent_registry
468 )
469 except FileNotFoundError as exc:
470 print(f"{plan_id}: {exc}", file=sys.stderr)
471 exit_code = 1
472 continue
473 except Exception as exc: # noqa: BLE001 - report and continue under --all
474 print(f"{plan_id}: cannot derive manifest: {exc}", file=sys.stderr)
475 exit_code = 1
476 continue
477 content = _serialize_manifest(manifest)
478 out_path = _manifest_path(plans_dir, plan_id)
479 if out_path.exists():
480 existing = out_path.read_text(encoding="utf-8")
481 if existing == content:
482 print(f"{plan_id}: up to date ({out_path})")
483 continue
484 if not args.force:
485 print(
486 f"{plan_id}: divergent manifest exists; pass --force to "
487 f"overwrite ({out_path})",
488 file=sys.stderr,
489 )
490 exit_code = 1
491 continue
492 _atomic_write(out_path, content)
493 written += 1
494 print(f"{plan_id}: wrote {out_path}")
495 _dbg("OC_CHAT.MAIN", func, f"init-manifest wrote {written}/{len(plan_ids)}", 2)
496 return exit_code
497
498
500 plan_id: str, plans_dir: Path, agent_registry: str | None
501) -> tuple[int, list[str]]:
502 """Validate one manifest. Returns ``(exit_code, report_lines)``.
503
504 Codes: 0 valid + in sync, 2 missing/unreadable, 3 Pydantic failure,
505 4 structurally valid but drifted from live state.
506 """
507 PlanManifest, _ = _load_contracts()
508 manifest_path = _manifest_path(plans_dir, plan_id)
509 if not manifest_path.is_file():
510 return 2, [f"{plan_id}: manifest file missing ({manifest_path})"]
511 try:
512 raw = manifest_path.read_text(encoding="utf-8")
513 except OSError as exc:
514 return 2, [f"{plan_id}: cannot read manifest: {exc}"]
515 try:
516 manifest = PlanManifest.model_validate_json(raw)
517 except Exception as exc: # noqa: BLE001 - Pydantic ValidationError + JSON errors
518 return 3, [f"{plan_id}: Pydantic validation failed: {exc}"]
519 try:
520 live = _derive_manifest(plan_id, plans_dir, agent_registry=agent_registry)
521 except Exception as exc: # noqa: BLE001 - missing/garbled live plan
522 return 4, [f"{plan_id}: cannot re-derive live state: {exc}"]
523
524 diffs: list[str] = []
525 for field_name in PlanManifest.model_fields:
526 on_disk = getattr(manifest, field_name)
527 live_value = getattr(live, field_name)
528 if on_disk != live_value:
529 diffs.append(
530 f"{plan_id}: {field_name}: manifest={on_disk!r} live={live_value!r}"
531 )
532 if diffs:
533 return 4, diffs
534 return 0, [f"{plan_id}: valid and matches live state"]
535
536
537def _cmd_validate_manifest(args: argparse.Namespace) -> int:
538 func = "plan_manage._cmd_validate_manifest"
539 _, plans_dir = _build_inventory(args)
540
541 if args.all:
542 manifests_dir = _manifests_dir(plans_dir)
543 manifest_files = (
544 sorted(manifests_dir.glob(f"*{MANIFEST_SUFFIX}"))
545 if manifests_dir.is_dir()
546 else []
547 )
548 if not manifest_files:
549 print("(no manifests found)")
550 return 0
551 worst = 0
552 passed = 0
553 for manifest_file in manifest_files:
554 plan_id = manifest_file.name[: -len(MANIFEST_SUFFIX)]
555 code, lines = _validate_one(plan_id, plans_dir, args.agent_registry)
556 for line in lines:
557 print(line)
558 if code == 0:
559 passed += 1
560 else:
561 worst = max(worst, code)
562 print(f"\n{passed}/{len(manifest_files)} manifest(s) valid + in sync.")
563 _dbg("OC_CHAT.MAIN", func, f"validate-all passed={passed} worst={worst}", 2)
564 return worst
565
566 if not args.plan:
567 print("error: must specify --plan PLAN_ID or --all", file=sys.stderr)
568 return 1
569 code, lines = _validate_one(args.plan, plans_dir, args.agent_registry)
570 for line in lines:
571 print(line)
572 _dbg("OC_CHAT.MAIN", func, f"validate {args.plan} -> {code}", 2)
573 return code
574
575
576def _cmd_list(args: argparse.Namespace) -> int:
577 func = "plan_manage._cmd_list"
578 inv, _ = _build_inventory(args)
579 records = list_plans(inventory=inv)
580 if args.status:
581 records = [r for r in records if r.status == args.status]
582 if not records:
583 print("(no plans)")
584 return 0
585 for r in records:
586 desc = r.description if len(r.description) <= 60 else r.description[:57] + "..."
587 print(f"{r.plan_id:<35} {r.status:<9} {r.created_at} {desc}")
588 _dbg("OC_CHAT.MAIN", func, f"listed {len(records)} record(s)", 2)
589 return 0
590
591
592def _cmd_status(args: argparse.Namespace) -> int:
593 inv, _ = _build_inventory(args)
594 rec = find_plan_by_id(args.plan_id, inventory=inv)
595 if rec is None:
596 print(f"No plan found for {args.plan_id!r}", file=sys.stderr)
597 return 1
598 print(f"plan_id: {rec.plan_id}")
599 print(f"status: {rec.status}")
600 print(f"description: {rec.description}")
601 print(f"created_at: {rec.created_at}")
602 print(f"completed_at: {rec.completed_at}")
603 print(f"aborted_at: {rec.aborted_at}")
604 print(f"session_name: {rec.session_name}")
605 return 0
606
607
608def _cmd_reconcile(args: argparse.Namespace) -> int:
609 func = "plan_manage._cmd_reconcile"
610 inv, plans_dir = _build_inventory(args)
611 result = reconcile(inv, plans_dir)
612 for renamed in result["renamed"]:
613 print(f"renamed: {renamed}")
614 for absorbed in result["orphans_absorbed"]:
615 print(f"absorbed orphan: {absorbed}")
616 for missing in result["missing_files"]:
617 print(f"missing file for inventory record: {missing}")
618 n_changes = (
619 len(result["renamed"])
620 + len(result["orphans_absorbed"])
621 + len(result["missing_files"])
622 )
623 if n_changes == 0:
624 print("(inventory and filesystem agree)")
625 _dbg("OC_CHAT.MAIN", func, f"reconcile: {n_changes} report line(s)", 2)
626 return 0
627
628
629def _cmd_complete(args: argparse.Namespace) -> int:
630 func = "plan_manage._cmd_complete"
631 inv, plans_dir = _build_inventory(args)
632 if inv.get(args.plan_id) is None:
633 inv.register(args.plan_id, description="", session_name=None)
634 _dbg("OC_CHAT.MAIN", func, f"registered orphan {args.plan_id!r}", 3)
635 rec = inv.get(args.plan_id)
636 if rec is None:
637 print(f"unknown plan_id: {args.plan_id!r}", file=sys.stderr)
638 return 1
639 if rec.status == "aborted":
640 print(
641 f"plan {args.plan_id!r} is in terminal status 'aborted'; "
642 f"cannot complete",
643 file=sys.stderr,
644 )
645 return 1
646 if rec.status == "complete":
647 print(
648 f"{rec.plan_id}: already complete "
649 f"(completed_at={rec.completed_at})"
650 )
651 _dbg("OC_CHAT.MAIN", func, f"already complete: {args.plan_id!r}", 3)
652 return 0
653 rec = complete_plan(args.plan_id, inventory=inv, plans_dir=plans_dir)
654 print(f"{rec.plan_id}: status={rec.status} completed_at={rec.completed_at}")
655 _dbg("OC_CHAT.MAIN", func, f"marked complete: {args.plan_id!r}", 2)
656 return 0
657
658
659def _cmd_archive(args: argparse.Namespace) -> int:
660 func = "plan_manage._cmd_archive"
661 inv, plans_dir = _build_inventory(args)
662
663 if args.all_complete and args.plan_id:
664 print(
665 "error: cannot combine --all-complete with a PLAN_ID positional",
666 file=sys.stderr,
667 )
668 return 1
669 if not args.all_complete and not args.plan_id:
670 print(
671 "error: must specify a PLAN_ID or pass --all-complete",
672 file=sys.stderr,
673 )
674 return 1
675
676 if args.all_complete:
677 records = inv.list_by_status("complete")
678 else:
679 rec = inv.get(args.plan_id)
680 if rec is None:
681 print(f"unknown plan_id: {args.plan_id!r}", file=sys.stderr)
682 return 1
683 if rec.status != "complete":
684 print(
685 f"plan {args.plan_id!r} status is {rec.status!r}; "
686 f"run 'complete' first",
687 file=sys.stderr,
688 )
689 return 1
690 records = [rec]
691
692 old_dir = plans_dir / "old"
693 old_dir.mkdir(parents=True, exist_ok=True)
694
695 moved = 0
696 for rec in records:
697 src_name = f"{rec.plan_id}_complete.md"
698 src = plans_dir / src_name
699 dst = old_dir / src_name
700 if dst.exists() and not src.exists():
701 continue # already archived — idempotent
702 if not src.exists():
703 print(f"missing source file: {src}", file=sys.stderr)
704 continue
705 src.rename(dst)
706 print(f"archived: {src_name} -> old/")
707 moved += 1
708 print(f"({moved} archived)")
709 _dbg("OC_CHAT.MAIN", func, f"archived {moved} file(s)", 2)
710 return 0
711
712
713def _find_repo_root(start: Path) -> Path:
714 """Walk upward to the nearest git worktree root."""
715 current = start.resolve()
716 for candidate in (current, *current.parents):
717 if (candidate / ".git").exists():
718 return candidate
719 raise ValueError(f"no git worktree found above {start}")
720
721
722def _cmd_agents(args: argparse.Namespace) -> int:
723 """Render the runtime agent-instance registry without mutating it."""
725 AgentInstanceRegistry,
726 AgentRegistryError,
727 )
728
729 try:
730 # The cwd is consulted only when nothing else names a registry
731 # (plan 278 P2): an explicit --registry is an answer, and a view
732 # that already has one has no business refusing because of where
733 # the process happens to stand.
734 if args.registry:
735 registry_path = Path(args.registry).resolve()
736 else:
737 registry_path = (
738 _find_repo_root(Path.cwd())
739 / "oc_chat"
740 / ".option_c"
741 / "orchestration"
742 / "agents.sqlite"
743 )
744 active_only = args.agents_view == "active"
745 instances = AgentInstanceRegistry(registry_path).list(
746 active_only=active_only
747 )
748 except (AgentRegistryError, OSError, ValueError) as exc:
749 print(f"agents view refused: {exc}", file=sys.stderr)
750 return 1
751
752 if not instances:
753 qualifier = "active " if active_only else ""
754 print(f"(no {qualifier}agent instances)")
755 return 0
756
757 print(
758 f"{'AGENT':<12} {'STATE':<14} {'PLAN':<30} "
759 f"{'MODEL':<24} {'THINKING':<9} HARNESS"
760 )
761 for instance in instances:
762 print(
763 f"{instance.agent_id:<12} {instance.state:<14} "
764 f"{(instance.plan_id or '-'):<30} "
765 f"{instance.model_attested:<24} {instance.thinking:<9} "
766 f"{instance.harness}"
767 )
768 return 0
769
770
771def _cmd_prepare_worktree(args: argparse.Namespace) -> int:
772 """Create a plan's branch, worktree and per-worktree uv venv (plan 241)."""
773 func = "plan_manage._cmd_prepare_worktree"
774 from oc_orchestrator.dispatch import DispatchError, resolve_plan_anchors
775 from oc_orchestrator.worktree_env import (
776 PrepareRequest,
777 WorktreeEnvError,
778 prepare_worktree,
779 )
780
781 try:
782 repo_root = (
783 Path(args.repo_root).resolve()
784 if args.repo_root
785 else _find_repo_root(Path.cwd())
786 )
787 plans_dir = Path(args.plans_dir or DEFAULT_PLANS_DIR).resolve()
788 anchors = resolve_plan_anchors(plans_dir, args.plan_number)
789 result = prepare_worktree(
790 PrepareRequest(
791 repo_root=repo_root,
792 branch=anchors.branch,
793 worktree=anchors.worktree,
794 base_sha=args.base,
795 uv_executable=args.uv,
796 uv_cache_dir=Path(args.uv_cache_dir) if args.uv_cache_dir else None,
797 smoke=bool(getattr(args, "smoke", False)),
798 )
799 )
800 except (DispatchError, WorktreeEnvError, OSError, ValueError) as exc:
801 print(f"prepare-worktree refused: {exc}", file=sys.stderr)
802 return 1
803 for line in result.report_lines():
804 print(line)
805 _dbg("OC_CHAT.MAIN", func, f"prepared {anchors.plan_id}", 2)
806 return 0
807
808
809#: Where ``teardown-worktree`` looks for a plan, in order (plan 243 P5).
810#: The close ceremony moves a plan into ``completed/`` at step 7b and only
811#: then removes the worktree at step 9, so resolving the active directory
812#: alone refused exactly when the tool was wanted.
813_TEARDOWN_PLAN_DIRS = ("plans/", "plans/completed/")
814
815
816def _resolve_teardown_plan(plans_dir: Path, plan_number: str) -> tuple[Any, str]:
817 """Resolve a plan's anchors from ``plans/``, then ``plans/completed/``.
818
819 Returns the anchors and the label of the directory they came from, so
820 the report line says which one answered. A plan in neither place keeps
821 the canonical refusal (including "expected exactly three digits" for a
822 malformed number) and names the completed directory as well.
823 """
824 from oc_orchestrator.dispatch import DispatchError, resolve_plan_anchors
825
826 completed = plans_dir / "completed"
827 for directory, label in zip((plans_dir, completed), _TEARDOWN_PLAN_DIRS):
828 if sorted(directory.glob(f"{plan_number}-*.md")):
829 return resolve_plan_anchors(directory, plan_number), label
830 try:
831 resolve_plan_anchors(plans_dir, plan_number)
832 except DispatchError as exc:
833 raise DispatchError(f"{exc}; {completed} holds no matching plan either") from exc
834 raise DispatchError(
835 f"plan number {plan_number} resolves in neither {plans_dir} nor {completed}"
836 )
837
838
839def _cmd_teardown_worktree(args: argparse.Namespace) -> int:
840 """Remove a plan's worktree and venv, naming any residue (plan 241 P5)."""
841 func = "plan_manage._cmd_teardown_worktree"
842 from oc_orchestrator.dispatch import DispatchError
843 from oc_orchestrator.worktree_env import WorktreeEnvError, teardown_worktree
844
845 origin = None
846 try:
847 repo_root = (
848 Path(args.repo_root).resolve()
849 if args.repo_root
850 else _find_repo_root(Path.cwd())
851 )
852 if args.worktree:
853 worktree = Path(args.worktree)
854 branch = None
855 else:
856 plans_dir = Path(args.plans_dir or DEFAULT_PLANS_DIR).resolve()
857 anchors, origin = _resolve_teardown_plan(plans_dir, args.plan_number)
858 worktree = anchors.worktree
859 branch = anchors.branch
860 result = teardown_worktree(repo_root, worktree, branch=branch)
861 except (DispatchError, WorktreeEnvError, OSError, ValueError) as exc:
862 print(f"teardown-worktree refused: {exc}", file=sys.stderr)
863 return 1
864 stream = sys.stdout if result.removed else sys.stderr
865 if origin is not None:
866 print(f"Resolved plan {args.plan_number} from {origin}", file=stream)
867 for line in result.report_lines():
868 print(line, file=stream)
869 _dbg("OC_CHAT.MAIN", func, f"teardown removed={result.removed}", 2)
870 return 0 if result.removed else 1
871
872
873def _dispatch_hooks(repo_root: Path):
874 """The hooks one dispatch announces through: none, by default.
875
876 Plan 259 P6 made the listener something the CALLER registers:
877 ``dispatch.py`` declares a Protocol and a ``NO_HOOKS`` default and
878 names neither the hub nor the queue, exactly as it declares nothing
879 about the registry. Plan 270 P3 moved the registration OUT of this
880 module, along with the hub and the queue it named: this package has
881 neither, so its default announces nothing and a standalone dispatch
882 reaches no log at all.
883
884 A consumer passes its own factory to :func:`main` as
885 ``dispatch_hooks``. ``oc_chat``'s ``scripts/plan_manage.py`` shim
886 passes the live hub hooks, so a dispatch there still records the
887 ``dispatch`` message and the ``dequeue`` it always did.
888 """
889 func = "plan_manage._dispatch_hooks"
890 from oc_orchestrator.dispatch import NO_HOOKS
891
892 _dbg("OC_CHAT.MAIN", func, f"no dispatch hooks registered for {repo_root}", 4)
893 return NO_HOOKS
894
895
897 args: argparse.Namespace,
898 *,
899 dispatch_hooks: Callable[[Path], Any] | None = None,
900) -> int:
901 """Run the plan-221 one-shot dispatch workflow."""
902 from oc_orchestrator.agent_instances import AgentRegistryError
903 from oc_orchestrator.dispatch import (
904 DispatchError,
905 DispatchRequest,
906 dispatch,
907 format_dispatch_suggestion,
908 )
909
910 try:
911 repo_root = (
912 Path(args.repo_root).resolve()
913 if args.repo_root
914 else _find_repo_root(Path.cwd())
915 )
916 plans_dir = Path(args.plans_dir or DEFAULT_PLANS_DIR).resolve()
917 registry_path = (
918 Path(args.registry).resolve()
919 if args.registry
920 else repo_root / "oc_chat" / ".option_c" / "orchestration" / "agents.sqlite"
921 )
922 launch_dir = (
923 Path(args.launch_dir).resolve()
924 if args.launch_dir
925 else repo_root / "oc_chat" / ".oc_chat" / "launch"
926 )
927 request = DispatchRequest(
928 hooks=(dispatch_hooks or _dispatch_hooks)(repo_root),
929 plan_number=args.plan_number,
930 plans_dir=plans_dir,
931 repo_root=repo_root,
932 registry_path=registry_path,
933 launch_dir=launch_dir,
934 model_attested=args.model,
935 thinking=args.thinking,
936 harness=args.harness,
937 template_path=(
938 Path(args.template).resolve() if args.template else None
939 ),
940 again_note=args.again,
941 shared_venv=args.shared_venv,
942 uv_executable=args.uv,
943 uv_cache_dir=Path(args.uv_cache_dir) if args.uv_cache_dir else None,
944 )
945 result = dispatch(request)
946 except (AgentRegistryError, DispatchError, OSError, ValueError) as exc:
947 print(f"dispatch refused: {exc}", file=sys.stderr)
948 return 1
949 for line in format_dispatch_suggestion(
950 result,
951 model_attested=args.model,
952 registry_path=registry_path,
953 ):
954 print(line)
955 return 0
956
957
958def _add_uv_arguments(parser: argparse.ArgumentParser) -> None:
959 """Attach the shared plan-241 uv knobs to a sub-parser."""
960 parser.add_argument(
961 "--uv",
962 default="uv",
963 help="uv executable name or path (refusal witnesses patch this)",
964 )
965 parser.add_argument(
966 "--uv-cache-dir",
967 help=(
968 "uv cache directory; must be on the venv's drive (default: "
969 "<repo root>/.uv-cache)"
970 ),
971 )
972
973
974#: How this CLI names itself in ``--help``. A consumer that exposes the
975#: tool under its own module path passes its own (plan 270 P4): the
976#: shim's help output must stay byte-identical to what it printed before
977#: the split, and ``prog`` is the only line in it that could differ.
978DEFAULT_PROG = "python -m oc_orchestrator.plan_manage"
979
980
982 argv: list[str] | None = None,
983 *,
984 prog: str = DEFAULT_PROG,
985 dispatch_hooks: Callable[[Path], Any] | None = None,
986) -> int:
987 """Entry point. Returns an exit code (0 success, 1 failure).
988
989 *prog* and *dispatch_hooks* are the consumer seams (plan 270 P4).
990 Both default to this package standing alone: it names itself, and a
991 dispatch announces itself to nobody. ``oc_chat``'s shim passes its
992 own module path and the live hub hooks, which is what keeps every
993 output, every hub entry and every queue transition identical across
994 the split.
995 """
996 parser = argparse.ArgumentParser(
997 prog=prog,
998 description="Query and heal the OC Chat plan inventory.",
999 )
1000 parser.add_argument(
1001 "--inventory",
1002 help=f"Path to inventory JSON (default: {DEFAULT_PLANS_DIR}/.inventory.json)",
1003 )
1004 parser.add_argument(
1005 "--plans-dir",
1006 help=f"Path to plans directory (default: {DEFAULT_PLANS_DIR})",
1007 )
1008 sub = parser.add_subparsers(dest="sub", required=True)
1009
1010 p_list = sub.add_parser("list", help="List plans, optionally filtered by status")
1011 p_list.add_argument(
1012 "--status",
1013 choices=VALID_STATUSES,
1014 help="Filter to plans with this status only",
1015 )
1016
1017 p_status = sub.add_parser("status", help="Show full record for one plan")
1018 p_status.add_argument("plan_id", help="Plan id or 3-digit prefix")
1019
1020 sub.add_parser("reconcile", help="Heal disk/inventory drift")
1021
1022 p_complete = sub.add_parser(
1023 "complete", help="Mark a plan complete and rename file to *_complete.md"
1024 )
1025 p_complete.add_argument(
1026 "plan_id",
1027 help="Plan id (full stem, e.g. 030-enhanced-terminal-ui-with-realtime-stats)",
1028 )
1029
1030 p_archive = sub.add_parser(
1031 "archive", help="Move *_complete.md files to plans/old/"
1032 )
1033 p_archive.add_argument(
1034 "plan_id",
1035 nargs="?",
1036 help="Plan id to archive (omit when using --all-complete)",
1037 )
1038 p_archive.add_argument(
1039 "--all-complete",
1040 action="store_true",
1041 help="Archive every record with status='complete'",
1042 )
1043
1044 p_agents = sub.add_parser(
1045 "agents", help="Inspect runtime agent instances"
1046 )
1047 agents_sub = p_agents.add_subparsers(dest="agents_view", required=True)
1048 for view, help_text in (
1049 ("list", "List every runtime agent instance"),
1050 ("active", "List non-closed runtime agent instances"),
1051 ):
1052 p_agents_view = agents_sub.add_parser(view, help=help_text)
1053 p_agents_view.add_argument(
1054 "--registry", help="agents.sqlite path override"
1055 )
1056
1057 p_dispatch = sub.add_parser(
1058 "dispatch",
1059 help="Create/reuse git anchors, allocate an instance, and render one launch file",
1060 )
1061 p_dispatch.add_argument("plan_number", help="Three-digit operator plan handle")
1062 p_dispatch.add_argument(
1063 "--model", required=True, help="Operator-attested model assignment"
1064 )
1065 p_dispatch.add_argument(
1066 "--thinking",
1067 choices=["off", "low", "medium", "high", "max"],
1068 help="Override requires.thinking (defaults to the plan requirement)",
1069 )
1070 p_dispatch.add_argument(
1071 "--harness",
1072 default="other",
1073 choices=["claude-code", "oc_chat", "openai", "other"],
1074 help="Harness kind recorded in the runtime instance registry",
1075 )
1076 p_dispatch.add_argument(
1077 "--again",
1078 metavar="NOTE",
1079 help="Close the plan's dead open instance with NOTE and allocate a successor",
1080 )
1081 p_dispatch.add_argument("--repo-root", help="Git root override (tests/operators)")
1082 p_dispatch.add_argument("--registry", help="agents.sqlite path override")
1083 p_dispatch.add_argument("--launch-dir", help="Rendered launch directory override")
1084 p_dispatch.add_argument(
1085 "--template",
1086 help="Committed launch-template override (used by refusal witnesses)",
1087 )
1088 p_dispatch.add_argument(
1089 "--shared-venv",
1090 action="store_true",
1091 help=(
1092 "Render the legacy shared-venv brief and skip building the "
1093 "worktree's own uv environment (plan 241 P4)"
1094 ),
1095 )
1096 _add_uv_arguments(p_dispatch)
1097
1098 p_prepare = sub.add_parser(
1099 "prepare-worktree",
1100 help=(
1101 "Create a plan's branch and worktree and build a uv venv inside "
1102 "it whose editable installs point at that worktree"
1103 ),
1104 )
1105 p_prepare.add_argument("plan_number", help="Three-digit operator plan handle")
1106 p_prepare.add_argument("--repo-root", help="Git root override (tests/operators)")
1107 p_prepare.add_argument(
1108 "--base",
1109 help=(
1110 "Commit the branch must start at; refuses when an existing branch "
1111 "sits elsewhere (default: reuse an existing branch, else master)"
1112 ),
1113 )
1114 p_prepare.add_argument(
1115 "--smoke",
1116 action="store_true",
1117 help=(
1118 "After verification, run the worker's own first commands inside "
1119 "the worktree (plan_status --help and the dev-engine imports) and "
1120 "refuse by name when one fails; dispatch always does (plan 243 P4)"
1121 ),
1122 )
1123 _add_uv_arguments(p_prepare)
1124
1125 p_teardown = sub.add_parser(
1126 "teardown-worktree",
1127 help=(
1128 "Remove a plan's worktree and its venv, naming any residue; "
1129 "resolves the plan from plans/ and then plans/completed/"
1130 ),
1131 )
1132 p_teardown.add_argument("plan_number", help="Three-digit operator plan handle")
1133 p_teardown.add_argument("--repo-root", help="Git root override (tests/operators)")
1134 p_teardown.add_argument(
1135 "--worktree",
1136 help="Worktree path override (skips plan-frontmatter resolution)",
1137 )
1138
1139 p_init_manifest = sub.add_parser(
1140 "init-manifest",
1141 help=(
1142 "Derive a PlanManifest from a plan file's header + queue state "
1143 "and write canonical JSON to plans/manifests/<plan_id>.manifest.json"
1144 ),
1145 )
1146 p_init_manifest.add_argument(
1147 "--plan", help="Plan id stem to derive (e.g. 093-m1-4-plan-manage-...)"
1148 )
1149 p_init_manifest.add_argument(
1150 "--all",
1151 action="store_true",
1152 help="Process every numbered plan file under the plans directory",
1153 )
1154 p_init_manifest.add_argument(
1155 "--force",
1156 action="store_true",
1157 help="Overwrite an existing manifest whose content differs",
1158 )
1159 p_init_manifest.add_argument(
1160 "--agent-registry",
1161 default=None,
1162 help=(
1163 "agents.sqlite path; when given, suggested_model takes the "
1164 "dispatch-attested value for this plan instead of 'unknown'"
1165 ),
1166 )
1167
1168 p_validate_manifest = sub.add_parser(
1169 "validate-manifest",
1170 help=(
1171 "Validate a manifest with option_c_agent_contracts.PlanManifest "
1172 "and compare it field-by-field against live plan + queue state"
1173 ),
1174 )
1175 p_validate_manifest.add_argument(
1176 "--plan", help="Plan id stem to validate"
1177 )
1178 p_validate_manifest.add_argument(
1179 "--all",
1180 action="store_true",
1181 help="Validate every manifest on disk under plans/manifests/",
1182 )
1183 p_validate_manifest.add_argument(
1184 "--agent-registry",
1185 default=None,
1186 help=(
1187 "agents.sqlite path; when given, suggested_model takes the "
1188 "dispatch-attested value for this plan instead of 'unknown'"
1189 ),
1190 )
1191
1192 args = parser.parse_args(argv)
1193 if args.sub == "list":
1194 return _cmd_list(args)
1195 if args.sub == "status":
1196 return _cmd_status(args)
1197 if args.sub == "reconcile":
1198 return _cmd_reconcile(args)
1199 if args.sub == "complete":
1200 return _cmd_complete(args)
1201 if args.sub == "archive":
1202 return _cmd_archive(args)
1203 if args.sub == "agents":
1204 return _cmd_agents(args)
1205 if args.sub == "dispatch":
1206 return _cmd_dispatch(args, dispatch_hooks=dispatch_hooks)
1207 if args.sub == "prepare-worktree":
1208 return _cmd_prepare_worktree(args)
1209 if args.sub == "teardown-worktree":
1210 return _cmd_teardown_worktree(args)
1211 if args.sub == "init-manifest":
1212 return _cmd_init_manifest(args)
1213 if args.sub == "validate-manifest":
1214 return _cmd_validate_manifest(args)
1215 parser.error(f"unknown sub-command: {args.sub}")
1216 return 1
1217
1218
1219if __name__ == "__main__":
1220 sys.exit(main())
int main(list[str]|None argv=None, *, str prog=DEFAULT_PROG, Callable[[Path], Any]|None dispatch_hooks=None)
list[str] _derive_assigned_agents(dict[str, object] yaml_data)
int _cmd_archive(argparse.Namespace args)
int _cmd_reconcile(argparse.Namespace args)
int _cmd_init_manifest(argparse.Namespace args)
int _cmd_validate_manifest(argparse.Namespace args)
tuple[PlanInventory, Path] _build_inventory(argparse.Namespace args)
int _cmd_teardown_worktree(argparse.Namespace args)
dict[str, str] _derive_requires(dict[str, object] yaml_data, str plan_id)
str _derive_status(str worktree)
int _cmd_dispatch(argparse.Namespace args, *, Callable[[Path], Any]|None dispatch_hooks=None)
Any _derive_manifest(str plan_id, Path plans_dir, *, str|None agent_registry=None, PlanPaths|None paths=None)
tuple[type, Any] _load_contracts()
tuple[int, list[str]] _validate_one(str plan_id, Path plans_dir, str|None agent_registry)
str|None _attested_model(str plan_id, str|None agent_registry)
_dispatch_hooks(Path repo_root)
dict[str, Any] _parse_plan_header(str text, str plan_id, *, str|None attested_model=None, PlanMetadataProvider|None provider=None)
int _cmd_list(argparse.Namespace args)
Path _find_repo_root(Path start)
int _cmd_complete(argparse.Namespace args)
int _cmd_status(argparse.Namespace args)
Path _manifests_dir(Path plans_dir)
None _add_uv_arguments(argparse.ArgumentParser parser)
list[str] _resolve_target_plan_ids(argparse.Namespace args, Path plans_dir)
Path _manifest_path(Path plans_dir, str plan_id)
None _atomic_write(Path path, str content)
tuple[Any, str] _resolve_teardown_plan(Path plans_dir, str plan_number)
str _serialize_manifest(Any manifest)
int _cmd_prepare_worktree(argparse.Namespace args)
int _cmd_agents(argparse.Namespace args)