7CLI for direct plan-inventory operations (Phase J).
11Lets humans query and heal the plan inventory without needing OC Chat
12to be running. Subcommands:
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/``.
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).
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).
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.
42 L2 — CLI lifecycle events
43 L3 — per-subcommand semantic details
44 L4 — per-record traces
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.
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)
66from __future__
import annotations
74from collections.abc
import Callable, Mapping
75from pathlib
import Path
78from oc_diagnostics
import _dbg
97 """Construct an inventory instance per CLI args."""
98 plans_dir = Path(args.plans_dir)
if args.plans_dir
else DEFAULT_PLANS_DIR
100 Path(args.inventory)
if args.inventory
101 else plans_dir /
".inventory.json"
114MANIFESTS_DIRNAME =
"manifests"
115MANIFEST_SUFFIX =
".manifest.json"
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"}
128_MODEL_RE = re.compile(
130 r"\*\*Suggested model:\*\*\s*`?(?P<value>[A-Za-z0-9._-]+)", re.MULTILINE
133_VALID_TIER = frozenset({
"frontier",
"strong",
"fast"})
134_VALID_THINKING = frozenset({
"off",
"low",
"medium",
"high",
"max"})
135_VALID_SESSION = frozenset({
"fresh",
"continue",
"either"})
139 """Import ``PlanManifest`` + ``canonical_json`` from the M1.2 package.
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
147 from option_c_agent_contracts
import PlanManifest
148 from option_c_agent_contracts.canonical
import canonical_json
150 return PlanManifest, canonical_json
154 return plans_dir / MANIFESTS_DIRNAME
162 """Derive ``PlanManifest.assigned_agents`` from plan frontmatter.
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.
173 func =
"_derive_assigned_agents"
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)
182 validate_agent_id(agent_identity)
183 _dbg(
"OC_CHAT.MAIN", func, f
"assigned_agents=[{agent_identity}]", 3)
184 return [agent_identity]
188 """Derive ``PlanManifest.requires`` from the frontmatter ``requires:`` block.
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.
194 Only ``tier`` / ``thinking`` / ``session`` are lifted; sibling keys such
195 as ``repo:`` belong to dispatch, and ``PlanRequirements`` is
198 func =
"_derive_requires"
199 raw = yaml_data.get(
"requires")
200 if not isinstance(raw, dict):
202 f
"plan {plan_id} has no 'requires:' frontmatter block "
203 "(expected tier/thinking/session); refusing to default it"
206 resolved: dict[str, str] = {}
208 (
"tier", _VALID_TIER),
209 (
"thinking", _VALID_THINKING),
210 (
"session", _VALID_SESSION),
212 supplied = raw.get(key)
213 value = str(supplied
or "").strip().lower()
214 if value
not in valid:
216 f
"plan {plan_id} has invalid requires.{key} {supplied!r}; "
217 f
"expected one of {sorted(valid)}"
219 resolved[key] = value
221 _dbg(
"OC_CHAT.MAIN", func, f
"requires={resolved}", 3)
229 attested_model: str |
None =
None,
230 provider: PlanMetadataProvider |
None =
None,
232 """Derive header fields from a plan file's text.
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
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.
256 meta = resolve_provider(provider).metadata_from_text(text)
257 if meta.frontmatter_error
is not None:
263 raise meta.frontmatter_error
264 yaml_data: Mapping[str, object] = meta.frontmatter
266 def _md(rx: re.Pattern[str]) -> str |
None:
267 match = rx.search(text)
270 return match.group(
"value").strip().strip(
"`").strip()
274 stem = re.sub(
r"^\d{3}-",
"", plan_id)
275 branch = f
"feat/{stem}"
277 worktree = meta.worktree
279 slug = branch.split(
"/", 1)[-1].replace(
"-",
"_")
280 worktree = f
"G:\\PythonApps\\OC_Chat_{slug}"
286 str(attested_model
or "").strip()
287 or str(yaml_data.get(
"model")
or _md(_MODEL_RE)
or "").strip()
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"],
303 """Map the worktree's ``.PLAN_STATUS.md`` to a manifest status.
305 Default ``pending`` (the registry spelling of "queued", per ADR-00Z)
306 when no worktree or no status file exists.
308 plan_status = importlib.import_module(
"oc_orchestrator.plan_status")
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)
320 """Return the model attested for *plan_id* at its latest dispatch.
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.
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.
333 func =
"_attested_model"
334 if not agent_registry:
341 for instance
in AgentInstanceRegistry(Path(agent_registry)).list()
342 if instance.plan_id == plan_id
345 _dbg(
"OC_CHAT.MAIN", func, f
"{plan_id}: registry unreadable", 3)
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
360 agent_registry: str |
None =
None,
361 paths: PlanPaths |
None =
None,
363 """Build a ``PlanManifest`` for *plan_id* from live plan + instance state.
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.
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.
378 plan_status = importlib.import_module(
"oc_orchestrator.plan_status")
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}")
385 raw = plan_path.read_bytes()
386 body_sha256 = hashlib.sha256(raw).hexdigest()
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"],
407 body_path=paths.plan_body_relpath(plan_id).as_posix(),
408 body_sha256=body_sha256,
414 """Canonical-JSON the manifest (sorted keys, no whitespace; ADR-00X §3.4)."""
416 return canonical_json(manifest.model_dump(mode=
"json"))
420 """Write *content* to *path* via a temp file + rename (atomic)."""
421 path.parent.mkdir(parents=
True, exist_ok=
True)
422 with tempfile.NamedTemporaryFile(
431 tmp_path = Path(tmp.name)
432 tmp_path.replace(path)
436 """Return the plan-id stems a manifest command should act on."""
437 if getattr(args,
"all",
False):
439 for path
in sorted(plans_dir.glob(
"*.md")):
440 if path.name
in _EXCLUDED_PLAN_FILES:
442 if not _PLAN_FILE_RE.match(path.name):
444 ids.append(path.stem)
446 if getattr(args,
"plan",
None):
452 func =
"plan_manage._cmd_init_manifest"
454 if not args.all
and not args.plan:
455 print(
"error: must specify --plan PLAN_ID or --all", file=sys.stderr)
459 print(
"(no matching plan files)")
464 for plan_id
in plan_ids:
467 plan_id, plans_dir, agent_registry=args.agent_registry
469 except FileNotFoundError
as exc:
470 print(f
"{plan_id}: {exc}", file=sys.stderr)
473 except Exception
as exc:
474 print(f
"{plan_id}: cannot derive manifest: {exc}", file=sys.stderr)
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})")
486 f
"{plan_id}: divergent manifest exists; pass --force to "
487 f
"overwrite ({out_path})",
494 print(f
"{plan_id}: wrote {out_path}")
495 _dbg(
"OC_CHAT.MAIN", func, f
"init-manifest wrote {written}/{len(plan_ids)}", 2)
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)``.
504 Codes: 0 valid + in sync, 2 missing/unreadable, 3 Pydantic failure,
505 4 structurally valid but drifted from live state.
509 if not manifest_path.is_file():
510 return 2, [f
"{plan_id}: manifest file missing ({manifest_path})"]
512 raw = manifest_path.read_text(encoding=
"utf-8")
513 except OSError
as exc:
514 return 2, [f
"{plan_id}: cannot read manifest: {exc}"]
516 manifest = PlanManifest.model_validate_json(raw)
517 except Exception
as exc:
518 return 3, [f
"{plan_id}: Pydantic validation failed: {exc}"]
521 except Exception
as exc:
522 return 4, [f
"{plan_id}: cannot re-derive live state: {exc}"]
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:
530 f
"{plan_id}: {field_name}: manifest={on_disk!r} live={live_value!r}"
534 return 0, [f
"{plan_id}: valid and matches live state"]
538 func =
"plan_manage._cmd_validate_manifest"
544 sorted(manifests_dir.glob(f
"*{MANIFEST_SUFFIX}"))
545 if manifests_dir.is_dir()
548 if not manifest_files:
549 print(
"(no manifests found)")
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)
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)
567 print(
"error: must specify --plan PLAN_ID or --all", file=sys.stderr)
569 code, lines =
_validate_one(args.plan, plans_dir, args.agent_registry)
572 _dbg(
"OC_CHAT.MAIN", func, f
"validate {args.plan} -> {code}", 2)
577 func =
"plan_manage._cmd_list"
579 records = list_plans(inventory=inv)
581 records = [r
for r
in records
if r.status == args.status]
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)
594 rec = find_plan_by_id(args.plan_id, inventory=inv)
596 print(f
"No plan found for {args.plan_id!r}", file=sys.stderr)
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}")
609 func =
"plan_manage._cmd_reconcile"
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}")
619 len(result[
"renamed"])
620 + len(result[
"orphans_absorbed"])
621 + len(result[
"missing_files"])
624 print(
"(inventory and filesystem agree)")
625 _dbg(
"OC_CHAT.MAIN", func, f
"reconcile: {n_changes} report line(s)", 2)
630 func =
"plan_manage._cmd_complete"
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)
637 print(f
"unknown plan_id: {args.plan_id!r}", file=sys.stderr)
639 if rec.status ==
"aborted":
641 f
"plan {args.plan_id!r} is in terminal status 'aborted'; "
646 if rec.status ==
"complete":
648 f
"{rec.plan_id}: already complete "
649 f
"(completed_at={rec.completed_at})"
651 _dbg(
"OC_CHAT.MAIN", func, f
"already complete: {args.plan_id!r}", 3)
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)
660 func =
"plan_manage._cmd_archive"
663 if args.all_complete
and args.plan_id:
665 "error: cannot combine --all-complete with a PLAN_ID positional",
669 if not args.all_complete
and not args.plan_id:
671 "error: must specify a PLAN_ID or pass --all-complete",
676 if args.all_complete:
677 records = inv.list_by_status(
"complete")
679 rec = inv.get(args.plan_id)
681 print(f
"unknown plan_id: {args.plan_id!r}", file=sys.stderr)
683 if rec.status !=
"complete":
685 f
"plan {args.plan_id!r} status is {rec.status!r}; "
686 f
"run 'complete' first",
692 old_dir = plans_dir /
"old"
693 old_dir.mkdir(parents=
True, exist_ok=
True)
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():
703 print(f
"missing source file: {src}", file=sys.stderr)
706 print(f
"archived: {src_name} -> old/")
708 print(f
"({moved} archived)")
709 _dbg(
"OC_CHAT.MAIN", func, f
"archived {moved} file(s)", 2)
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():
719 raise ValueError(f
"no git worktree found above {start}")
723 """Render the runtime agent-instance registry without mutating it."""
725 AgentInstanceRegistry,
735 registry_path = Path(args.registry).resolve()
744 active_only = args.agents_view ==
"active"
745 instances = AgentInstanceRegistry(registry_path).list(
746 active_only=active_only
748 except (AgentRegistryError, OSError, ValueError)
as exc:
749 print(f
"agents view refused: {exc}", file=sys.stderr)
753 qualifier =
"active " if active_only
else ""
754 print(f
"(no {qualifier}agent instances)")
758 f
"{'AGENT':<12} {'STATE':<14} {'PLAN':<30} "
759 f
"{'MODEL':<24} {'THINKING':<9} HARNESS"
761 for instance
in instances:
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}"
772 """Create a plan's branch, worktree and per-worktree uv venv (plan 241)."""
773 func =
"plan_manage._cmd_prepare_worktree"
783 Path(args.repo_root).resolve()
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(
792 branch=anchors.branch,
793 worktree=anchors.worktree,
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)),
800 except (DispatchError, WorktreeEnvError, OSError, ValueError)
as exc:
801 print(f
"prepare-worktree refused: {exc}", file=sys.stderr)
803 for line
in result.report_lines():
805 _dbg(
"OC_CHAT.MAIN", func, f
"prepared {anchors.plan_id}", 2)
813_TEARDOWN_PLAN_DIRS = (
"plans/",
"plans/completed/")
817 """Resolve a plan's anchors from ``plans/``, then ``plans/completed/``.
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.
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
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
835 f
"plan number {plan_number} resolves in neither {plans_dir} nor {completed}"
840 """Remove a plan's worktree and venv, naming any residue (plan 241 P5)."""
841 func =
"plan_manage._cmd_teardown_worktree"
848 Path(args.repo_root).resolve()
853 worktree = Path(args.worktree)
856 plans_dir = Path(args.plans_dir
or DEFAULT_PLANS_DIR).resolve()
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)
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
874 """The hooks one dispatch announces through: none, by default.
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.
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.
889 func =
"plan_manage._dispatch_hooks"
892 _dbg(
"OC_CHAT.MAIN", func, f
"no dispatch hooks registered for {repo_root}", 4)
897 args: argparse.Namespace,
899 dispatch_hooks: Callable[[Path], Any] |
None =
None,
901 """Run the plan-221 one-shot dispatch workflow."""
907 format_dispatch_suggestion,
912 Path(args.repo_root).resolve()
916 plans_dir = Path(args.plans_dir
or DEFAULT_PLANS_DIR).resolve()
918 Path(args.registry).resolve()
920 else repo_root /
"oc_chat" /
".option_c" /
"orchestration" /
"agents.sqlite"
923 Path(args.launch_dir).resolve()
925 else repo_root /
"oc_chat" /
".oc_chat" /
"launch"
927 request = DispatchRequest(
928 hooks=(dispatch_hooks
or _dispatch_hooks)(repo_root),
929 plan_number=args.plan_number,
932 registry_path=registry_path,
933 launch_dir=launch_dir,
934 model_attested=args.model,
935 thinking=args.thinking,
936 harness=args.harness,
938 Path(args.template).resolve()
if args.template
else None
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,
945 result = dispatch(request)
946 except (AgentRegistryError, DispatchError, OSError, ValueError)
as exc:
947 print(f
"dispatch refused: {exc}", file=sys.stderr)
949 for line
in format_dispatch_suggestion(
951 model_attested=args.model,
952 registry_path=registry_path,
959 """Attach the shared plan-241 uv knobs to a sub-parser."""
963 help=
"uv executable name or path (refusal witnesses patch this)",
968 "uv cache directory; must be on the venv's drive (default: "
969 "<repo root>/.uv-cache)"
978DEFAULT_PROG =
"python -m oc_orchestrator.plan_manage"
982 argv: list[str] |
None =
None,
984 prog: str = DEFAULT_PROG,
985 dispatch_hooks: Callable[[Path], Any] |
None =
None,
987 """Entry point. Returns an exit code (0 success, 1 failure).
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
996 parser = argparse.ArgumentParser(
998 description=
"Query and heal the OC Chat plan inventory.",
1000 parser.add_argument(
1002 help=f
"Path to inventory JSON (default: {DEFAULT_PLANS_DIR}/.inventory.json)",
1004 parser.add_argument(
1006 help=f
"Path to plans directory (default: {DEFAULT_PLANS_DIR})",
1008 sub = parser.add_subparsers(dest=
"sub", required=
True)
1010 p_list = sub.add_parser(
"list", help=
"List plans, optionally filtered by status")
1011 p_list.add_argument(
1013 choices=VALID_STATUSES,
1014 help=
"Filter to plans with this status only",
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")
1020 sub.add_parser(
"reconcile", help=
"Heal disk/inventory drift")
1022 p_complete = sub.add_parser(
1023 "complete", help=
"Mark a plan complete and rename file to *_complete.md"
1025 p_complete.add_argument(
1027 help=
"Plan id (full stem, e.g. 030-enhanced-terminal-ui-with-realtime-stats)",
1030 p_archive = sub.add_parser(
1031 "archive", help=
"Move *_complete.md files to plans/old/"
1033 p_archive.add_argument(
1036 help=
"Plan id to archive (omit when using --all-complete)",
1038 p_archive.add_argument(
1040 action=
"store_true",
1041 help=
"Archive every record with status='complete'",
1044 p_agents = sub.add_parser(
1045 "agents", help=
"Inspect runtime agent instances"
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"),
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"
1057 p_dispatch = sub.add_parser(
1059 help=
"Create/reuse git anchors, allocate an instance, and render one launch file",
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"
1065 p_dispatch.add_argument(
1067 choices=[
"off",
"low",
"medium",
"high",
"max"],
1068 help=
"Override requires.thinking (defaults to the plan requirement)",
1070 p_dispatch.add_argument(
1073 choices=[
"claude-code",
"oc_chat",
"openai",
"other"],
1074 help=
"Harness kind recorded in the runtime instance registry",
1076 p_dispatch.add_argument(
1079 help=
"Close the plan's dead open instance with NOTE and allocate a successor",
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(
1086 help=
"Committed launch-template override (used by refusal witnesses)",
1088 p_dispatch.add_argument(
1090 action=
"store_true",
1092 "Render the legacy shared-venv brief and skip building the "
1093 "worktree's own uv environment (plan 241 P4)"
1098 p_prepare = sub.add_parser(
1101 "Create a plan's branch and worktree and build a uv venv inside "
1102 "it whose editable installs point at that worktree"
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(
1110 "Commit the branch must start at; refuses when an existing branch "
1111 "sits elsewhere (default: reuse an existing branch, else master)"
1114 p_prepare.add_argument(
1116 action=
"store_true",
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)"
1125 p_teardown = sub.add_parser(
1126 "teardown-worktree",
1128 "Remove a plan's worktree and its venv, naming any residue; "
1129 "resolves the plan from plans/ and then plans/completed/"
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(
1136 help=
"Worktree path override (skips plan-frontmatter resolution)",
1139 p_init_manifest = sub.add_parser(
1142 "Derive a PlanManifest from a plan file's header + queue state "
1143 "and write canonical JSON to plans/manifests/<plan_id>.manifest.json"
1146 p_init_manifest.add_argument(
1147 "--plan", help=
"Plan id stem to derive (e.g. 093-m1-4-plan-manage-...)"
1149 p_init_manifest.add_argument(
1151 action=
"store_true",
1152 help=
"Process every numbered plan file under the plans directory",
1154 p_init_manifest.add_argument(
1156 action=
"store_true",
1157 help=
"Overwrite an existing manifest whose content differs",
1159 p_init_manifest.add_argument(
1163 "agents.sqlite path; when given, suggested_model takes the "
1164 "dispatch-attested value for this plan instead of 'unknown'"
1168 p_validate_manifest = sub.add_parser(
1169 "validate-manifest",
1171 "Validate a manifest with option_c_agent_contracts.PlanManifest "
1172 "and compare it field-by-field against live plan + queue state"
1175 p_validate_manifest.add_argument(
1176 "--plan", help=
"Plan id stem to validate"
1178 p_validate_manifest.add_argument(
1180 action=
"store_true",
1181 help=
"Validate every manifest on disk under plans/manifests/",
1183 p_validate_manifest.add_argument(
1187 "agents.sqlite path; when given, suggested_model takes the "
1188 "dispatch-attested value for this plan instead of 'unknown'"
1192 args = parser.parse_args(argv)
1193 if args.sub ==
"list":
1195 if args.sub ==
"status":
1197 if args.sub ==
"reconcile":
1199 if args.sub ==
"complete":
1201 if args.sub ==
"archive":
1203 if args.sub ==
"agents":
1205 if args.sub ==
"dispatch":
1207 if args.sub ==
"prepare-worktree":
1209 if args.sub ==
"teardown-worktree":
1211 if args.sub ==
"init-manifest":
1213 if args.sub ==
"validate-manifest":
1215 parser.error(f
"unknown sub-command: {args.sub}")
1219if __name__ ==
"__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)