7Per-worktree ``.PLAN_STATUS.md`` lifecycle helper + CLI.
11Read, validate, write, and transition the per-worktree status file
12introduced in plan 062. The file lives at the worktree root, carries
13YAML frontmatter + a Markdown body, and tracks a single plan's
14lifecycle from ``Queued``/``Implementing`` through one of three
15terminal states (``Completed`` / ``Partially Completed`` / ``Failed``).
19- Pydantic models for the schema v1.0: :class:`PlanStatus`,
20 :class:`Progress`, :class:`AcceptanceChecks`, :class:`Status`.
21- :func:`read` / :func:`write` — frontmatter + body round-trip with
22 atomic writes (temp-then-rename).
23- :func:`update` / :func:`transition` — mutator helpers that enforce
24 the lifecycle state machine and timestamp invariants.
25- ``init`` / ``set`` / ``show`` CLI subcommands for hand-driven use.
26- ``report`` — the plan-231 worker-report lane writer: one JSON event in
27 the comms bus carrying the worker's full closing summary verbatim.
29The worker-report lane (plan 231, proposal V1 §5)
30-------------------------------------------------
31``report`` writes ONE file into ``<comms-root>/lanes/worker-reports/`` and
32does nothing else. It holds no credential, opens no socket, and — by
33design and by a structural test — **never imports oc_connect**: a worker
34reaching AWAITING GATE must not need connector knowledge to be heard. The
35orchestrator-side relay reads that lane and puts the summary on the
36operator's phone; the event schema it validates against is documented in
37``oc_connect/oc_connect/worker_reports.py``, which is the authority for
38the contract these two sides share without sharing an import.
44 L2 - lifecycle events (read, write, transition)
45 L3 - semantic details (invalid transitions, validation errors)
46 L4 - deep tracing (per-field parse / emit)
50- :func:`write` is atomic (temp file in the same dir, then ``replace``).
51- :func:`transition` enforces the state machine: forward-only, no
52 skipping ``Implementing``, terminal states are sinks. Raises
53 :class:`ValueError` on illegal transitions.
54- ``finished_at >= started_at >= queued_at`` when set; Pydantic
55 validates the relationship at model construction time.
56- Terminal-state body is recommended but not required at write time
57 (workers may set status first and fill body in a follow-up call).
59The observers (plan 257 P1, default inverted by plan 270 P3)
60-----------------------------------------------------------
61Every lifecycle event this tool records used to be mirrored into an
62orchestration registry by code that lived right here, which made that
63registry the dispatch layer's largest outbound edge. Plan 257 inverted
64the direction: this module announces events through
65:class:`oc_orchestrator.state_observer.StateObserver` and knows nothing
66about who listens. Plan 270 inverted the DEFAULT to match, because the
67registry belongs to a consumer and not to this package: passing no
68observers now means no observer, and a consumer that wants the mirror
69registers it. ``oc_chat``'s ``scripts/plan_status.py`` shim does exactly
70that, so nothing about its behaviour changed.
74- oc_diagnostics (structured debug logging via _dbg)
75- oc_orchestrator.plan_inventory (PlanPaths -- where plan files live)
76- oc_orchestrator.plan_metadata (the one plan-header reader)
77- oc_orchestrator.state_observer (the plan-state observation contract)
78- pydantic (v2 model validation)
79- yaml (frontmatter parse/emit)
80- oc_orchestrator.agent_instances (lazy; the default agent id check and live-instance lookup)
82from __future__
import annotations
90from datetime
import datetime, timezone
92from pathlib
import Path
93from typing
import Callable, Literal, Sequence
96from pydantic
import BaseModel, ConfigDict, field_validator, model_validator
98from oc_diagnostics
import _dbg
102 PlanMetadataProvider,
109_dbg(
"OC_CHAT.MAIN",
"module",
"loaded", 4)
112SCHEMA_VERSION =
"2.0"
113LEGACY_SCHEMA_VERSION =
"1.0"
114DEFAULT_FILENAME =
".PLAN_STATUS.md"
121WORKSPACE_MARKER =
".option_c_workspace.json"
122PROJECT_MARKER =
".option_c"
123COMMS_ANCHOR =
"oc_chat"
124COMMS_SUBPATH = (
".option_c",
"comms")
125WORKER_REPORT_LANE =
"worker-reports"
126WORKER_REPORT_SOURCE =
"worker"
127WORKER_REPORT_SCHEMA_VERSION =
"1.0"
128WORKER_REPORT_STATUSES = (
"awaiting_gate",
"blocked")
129DEFAULT_REPORT_FILENAME =
"report.md"
133_CROCKFORD =
"0123456789ABCDEFGHJKMNPQRSTVWXYZ"
147 """A refusal from the injected live-instance lookup."""
151AgentLookup = Callable[[str |
None, str],
None]
154AgentIdValidator = Callable[[str], str]
158 """Today's format check: ``agent_instances.validate_agent_id``."""
161 return validate_agent_id(value)
165 """Today's live check: the instance must exist and not be closed.
167 The refusal is re-raised as :class:`AgentLookupError` carrying the
168 original message unchanged, so the caller's ``agent validation
169 failed: {exc}`` line stays character-for-character what it was.
170 Path resolution stays inside this default because it reads
171 ``Path.cwd()`` at call time, and stays outside the ``try`` because
172 ``default_agent_registry`` cannot raise the error being caught.
174 func =
"plan_status._default_agent_lookup"
176 AgentInstanceRegistry,
178 default_agent_registry,
182 Path(registry_arg)
if registry_arg
else default_agent_registry(Path.cwd())
185 AgentInstanceRegistry(registry_path).require_live(agent_id)
186 except AgentRegistryError
as exc:
189 _dbg(
"OC_CHAT.MAIN", func, f
"VALIDATION_ERROR: {exc}", 3)
195_agent_id_validator: AgentIdValidator = _default_agent_id_validator
202 """Lifecycle status of a plan in a worktree."""
205 IMPLEMENTING =
"Implementing"
206 COMPLETED =
"Completed"
208 PARTIALLY_COMPLETED =
"Partially Completed"
212TERMINAL_STATUSES: frozenset[Status] = frozenset(
213 {Status.COMPLETED, Status.PARTIALLY_COMPLETED, Status.FAILED}
217_ALLOWED_TRANSITIONS: dict[Status |
None, frozenset[Status]] = {
218 None: frozenset({Status.QUEUED, Status.IMPLEMENTING}),
219 Status.QUEUED: frozenset({Status.IMPLEMENTING}),
220 Status.IMPLEMENTING: frozenset({Status.BLOCKED, *TERMINAL_STATUSES}),
221 Status.BLOCKED: frozenset({Status.IMPLEMENTING, Status.COMPLETED}),
222 Status.COMPLETED: frozenset(),
223 Status.PARTIALLY_COMPLETED: frozenset(),
224 Status.FAILED: frozenset(),
229 model_config = ConfigDict(extra=
"forbid")
230 steps_total: int |
None =
None
231 steps_done: int |
None =
None
232 current_step: str |
None =
None
236 model_config = ConfigDict(extra=
"forbid")
244 """Validated `.PLAN_STATUS.md` frontmatter (schema v1.0)."""
246 model_config = ConfigDict(extra=
"forbid", use_enum_values=
False)
248 schema_version: str = SCHEMA_VERSION
254 thinking: Literal[
"off",
"low",
"medium",
"high",
"max"]
255 session: Literal[
"fresh",
"continue",
"either"]
257 queued_at: datetime |
None =
None
258 started_at: datetime |
None =
None
259 finished_at: datetime |
None =
None
260 progress: Progress |
None =
None
261 acceptance_checks: AcceptanceChecks |
None =
None
263 @field_validator("schema_version")
266 if value
not in {LEGACY_SCHEMA_VERSION, SCHEMA_VERSION}:
268 f
"unsupported schema_version {value!r}; this helper reads "
269 f
"{LEGACY_SCHEMA_VERSION!r} and {SCHEMA_VERSION!r}"
273 @model_validator(mode="after")
276 if not self.
agent or not self.
agent.isalpha()
or not self.
agent.isupper():
278 "legacy schema agent must be uppercase letters, "
279 f
"got {self.agent!r}"
284 except ValueError
as exc:
285 raise ValueError(str(exc))
from exc
288 @model_validator(mode="after")
295 prev_name, prev_ts =
None,
None
296 for name, ts
in ordered:
299 if prev_ts
is not None and ts < prev_ts:
301 f
"timestamp order violated: {name}={ts!r} precedes "
302 f
"{prev_name}={prev_ts!r}"
304 prev_name, prev_ts = name, ts
315 return datetime.now().astimezone().replace(microsecond=0)
322_split_frontmatter = split_frontmatter
326 """Emit the YAML frontmatter for *status*, preserving field order."""
327 data = status.model_dump(mode=
"json")
328 return yaml.safe_dump(data, sort_keys=
False, default_flow_style=
False)
334def read(path: Path) -> tuple[PlanStatus, str]:
335 """Read ``.PLAN_STATUS.md`` and return ``(PlanStatus, markdown_body)``."""
336 func =
"plan_status.read"
337 text = Path(path).read_text(encoding=
"utf-8")
339 data = yaml.safe_load(frontmatter)
340 if not isinstance(data, dict):
341 raise ValueError(
"frontmatter must be a YAML mapping")
343 _dbg(
"OC_CHAT.MAIN", func, f
"read {path} status={status.status.value}", 2)
347def write(path: Path, status: PlanStatus, body: str =
"") ->
None:
348 """Atomically write ``.PLAN_STATUS.md`` to *path*."""
349 func =
"plan_status.write"
350 if status.schema_version != SCHEMA_VERSION:
352 f
"schema {status.schema_version!r} is read-only; "
353 f
"new writes require {SCHEMA_VERSION!r}"
357 payload = f
"---\n{frontmatter}---\n\n{body.lstrip()}" if body
else f
"---\n{frontmatter}---\n"
358 path.parent.mkdir(parents=
True, exist_ok=
True)
359 with tempfile.NamedTemporaryFile(
363 prefix=
".plan_status_",
368 tmp_path = Path(tmp.name)
369 tmp_path.replace(path)
370 _dbg(
"OC_CHAT.MAIN", func, f
"wrote {path} ({len(payload)}B)", 2)
373def update(path: Path, body: str |
None =
None, **fields: object) -> PlanStatus:
374 """Read, apply *fields* updates, validate, write back. Returns the new status."""
375 func =
"plan_status.update"
376 current, current_body =
read(path)
377 if "status" in fields:
379 data = current.model_dump(mode=
"json")
380 if "progress" in fields
and isinstance(fields[
"progress"], dict):
381 progress_data = dict(data.get(
"progress")
or {})
382 progress_data.update(fields[
"progress"])
383 data[
"progress"] = progress_data
384 fields = {k: v
for k, v
in fields.items()
if k !=
"progress"}
387 write(path, new_status, body
if body
is not None else current_body)
388 _dbg(
"OC_CHAT.MAIN", func, f
"updated {path}: {current.status.value}→{new_status.status.value}", 2)
392def transition(path: Path, new_status: Status, body: str |
None =
None) -> PlanStatus:
393 """Transition the status file at *path* to *new_status*.
395 Sets ``started_at`` if leaving ``Queued`` (or if the file had no
396 prior start timestamp), and ``finished_at`` on transitions into a
397 terminal state. Raises :class:`ValueError` on illegal transitions.
399 func =
"plan_status.transition"
400 current, current_body =
read(path)
402 data = current.model_dump(mode=
"json")
403 data[
"status"] = new_status.value
405 if new_status == Status.IMPLEMENTING
and current.started_at
is None:
406 data[
"started_at"] = now_iso
407 if new_status
in TERMINAL_STATUSES
and current.finished_at
is None:
408 data[
"finished_at"] = now_iso
409 if current.started_at
is None and data.get(
"started_at")
is None:
410 data[
"started_at"] = now_iso
412 write(path, new_status_obj, body
if body
is not None else current_body)
413 _dbg(
"OC_CHAT.MAIN", func, f
"{current.status.value} → {new_status.value} at {path}", 2)
414 return new_status_obj
418 func =
"_validate_transition"
419 allowed = _ALLOWED_TRANSITIONS.get(current, frozenset())
420 if target
not in allowed:
421 _dbg(
"OC_CHAT.MAIN", func, f
"illegal: {current} → {target}", 3)
423 f
"illegal status transition: {current.value if current else 'None'} "
432 """A worker-report event could not be written as specified."""
435class EmptySummaryError(WorkerReportError):
436 """The worker's closing summary was blank, so there is nothing to relay."""
440 """No workspace or project marker was found above the starting path."""
445 for _
in range(length):
446 value, remainder = divmod(value, 32)
447 digits.append(_CROCKFORD[remainder])
448 return "".join(reversed(digits))
451def new_ulid(now: datetime, *, entropy: bytes |
None =
None) -> str:
452 """Return a 26-character ULID: 48 bits of time, 80 bits of randomness.
454 Byte-for-byte the algorithm in ``oc_connect.comms.new_ulid``; see the
455 module docstring for why the worker side re-implements rather than
456 imports it. Time-first encoding makes lexical order arrival order,
457 which is what lets the relay read the lane with a plain directory sort.
459 if now.tzinfo
is None:
460 now = now.replace(tzinfo=timezone.utc)
461 milliseconds = int(now.astimezone(timezone.utc).timestamp() * 1000)
462 raw = entropy
if entropy
is not None else secrets.token_bytes(10)
464 raise ValueError(
"ULID entropy must be exactly ten bytes")
465 return _encode(milliseconds, 10) +
_encode(int.from_bytes(raw,
"big"), 16)
469 """Return a filename-safe UTC stamp; colons are illegal on Windows."""
470 if now.tzinfo
is None:
471 now = now.replace(tzinfo=timezone.utc)
472 return now.astimezone(timezone.utc).strftime(
"%Y%m%dT%H%M%SZ")
476 if now.tzinfo
is None:
477 now = now.replace(tzinfo=timezone.utc)
478 return now.astimezone(timezone.utc).isoformat().replace(
"+00:00",
"Z")
482 """Return the workspace-anchored comms-bus root above *start*.
484 The rules are ``oc_connect.workspace.comms_root``'s, restated: the
485 workspace marker wins over a nearer project marker because connector
486 state is workspace-wide, and the bus sits beside the dev engine's
487 orchestration state when an ``oc_chat`` subproject is present. Guessing
488 a root would post a worker's report somewhere nobody is listening, so a
489 failed walk raises instead.
491 origin = Path(start).expanduser().resolve()
492 project_fallback: Path |
None =
None
493 anchor: Path |
None =
None
494 for parent
in (origin, *origin.parents):
495 if (parent / WORKSPACE_MARKER).is_file():
498 if project_fallback
is None and (parent / PROJECT_MARKER).is_dir():
499 project_fallback = parent
501 anchor = project_fallback
504 f
"no Option C workspace marker ({WORKSPACE_MARKER}) or project "
505 f
"marker ({PROJECT_MARKER}/) was found at or above {origin}"
507 if (anchor / COMMS_ANCHOR).is_dir():
508 return anchor.joinpath(COMMS_ANCHOR, *COMMS_SUBPATH)
509 return anchor.joinpath(*COMMS_SUBPATH)
521 entropy: bytes |
None =
None,
522) -> dict[str, object]:
523 """Build one lane event; refuse by name when the summary is empty.
525 An empty summary is refused rather than defaulted because the whole
526 point of the lane is that the operator reads the worker's own words: a
527 report event with nothing in it would announce a gate and then say
528 nothing, which is worse than no event at all.
530 if status
not in WORKER_REPORT_STATUSES:
532 f
"worker report status must be one of "
533 f
"{', '.join(WORKER_REPORT_STATUSES)}; got {status!r}"
535 if not summary.strip():
537 "refusing to write a worker report event with an empty summary; "
538 "the lane exists to carry the worker's own closing words verbatim"
541 "schema_version": WORKER_REPORT_SCHEMA_VERSION,
542 "event_id":
new_ulid(now, entropy=entropy),
543 "lane": WORKER_REPORT_LANE,
544 "direction":
"inbound",
549 "worktree": str(worktree),
550 "report_path": str(report_path),
553 "summary_sha256": hashlib.sha256(summary.encode(
"utf-8")).hexdigest(),
554 "relayed_at_utc":
None,
555 "relay_chunk_count":
None,
561 event: dict[str, object],
565 """Write one event into ``lanes/worker-reports/`` and return its path.
567 The lane directory is created when absent: a worker must be able to
568 report from a workspace where the bus has never been initialised.
569 Nothing outside the lane is created, read, or written.
571 func =
"plan_status.write_worker_report"
572 lane_dir = Path(comms_root) /
"lanes" / WORKER_REPORT_LANE
573 lane_dir.mkdir(parents=
True, exist_ok=
True)
574 name = f
"{utc_stamp(now)}_{WORKER_REPORT_SOURCE}_{event['event_id']}.json"
575 path = lane_dir / name
578 f
"refusing to overwrite an existing worker report event at {path}"
580 payload = json.dumps(event, indent=2, sort_keys=
True) +
"\n"
581 with tempfile.NamedTemporaryFile(
586 prefix=
".worker_report_",
591 tmp_path = Path(tmp.name)
592 tmp_path.replace(path)
593 _dbg(
"OC_CHAT.MAIN", func, f
"wrote worker report event {path.name}", 2)
601 """Argparse type for the registry-allocated ``agent-NNN`` form."""
604 except ValueError
as exc:
605 raise argparse.ArgumentTypeError(str(exc))
from exc
609 """Where the status file lives: ``--path`` > ``--worktree-path`` >
610 the recorded absolute ``--worktree`` > cwd.
612 The third rung is the 2026-09-06 correction: the launch brief runs
613 workers from ``<worktree>/oc_chat`` and passes ``--worktree`` (the
614 field recorded INSIDE the file), so before this the file landed one
615 level below the worktree root where the gate reads it, and four plans
616 were wrongly recorded as having skipped STEP-0.
618 if getattr(args,
"path",
None):
619 return Path(args.path)
620 explicit = getattr(args,
"worktree_path",
None)
622 return Path(explicit) / DEFAULT_FILENAME
623 recorded = getattr(args,
"worktree",
None)
624 if recorded
and Path(recorded).is_absolute():
625 return Path(recorded) / DEFAULT_FILENAME
626 return Path.cwd() / DEFAULT_FILENAME
630 raw = getattr(args,
"worktree",
None)
632 candidate = Path(raw)
633 if candidate.is_absolute():
634 return candidate.resolve()
635 return Path(getattr(args,
"worktree_path",
None)
or Path.cwd()).resolve()
640 Status.QUEUED:
"pending",
641 Status.IMPLEMENTING:
"implementing",
642 Status.BLOCKED:
"implementing",
643 Status.COMPLETED:
"ready_for_merge",
644 Status.PARTIALLY_COMPLETED:
"partial",
645 Status.FAILED:
"failed",
650 prefix = plan_id.split(
"-", 1)[0]
653 except ValueError
as exc:
654 raise ValueError(f
"plan id {plan_id!r} does not start with a number")
from exc
656 raise ValueError(f
"plan id {plan_id!r} does not start with a positive number")
664 provider: PlanMetadataProvider |
None =
None,
665 paths: PlanPaths |
None =
None,
666) -> dict[str, str |
None]:
667 """Title, type and body hash for the plan file *plan_id* names.
669 The header fields come from the shared provider (plan 257 P2); the
670 body hash stays here because it is a property of the bytes on disk,
671 not of the header. The bytes are read once and reused for both.
673 Plan 270 P3: where the plan file lives is read off :class:`PlanPaths`
674 rather than from a ``Path("oc_chat") / "plans"`` literal, because a
675 standalone package cannot hard-code a consumer's layout. The default
676 is byte-identical to that literal.
678 func =
"plan_status._plan_metadata"
679 relative_path = (paths
or PlanPaths()).plan_body_relpath(plan_id)
680 plan_path = worktree / relative_path
681 if not plan_path.is_file():
682 _dbg(
"OC_CHAT.MAIN", func, f
"no plan file at {plan_path}", 3)
685 "body_path": relative_path.as_posix(),
686 "body_sha256": hashlib.sha256(b
"").hexdigest(),
689 body = plan_path.read_bytes()
690 text = body.decode(
"utf-8", errors=
"replace")
691 meta = resolve_provider(provider).metadata_from_text(text, plan_id=plan_id)
693 "title": meta.title
or plan_id,
694 "body_path": relative_path.as_posix(),
695 "body_sha256": hashlib.sha256(body).hexdigest(),
696 "plan_type": meta.plan_type,
701 observers: Sequence[StateObserver] |
None,
702) -> tuple[StateObserver, ...]:
703 """The observers to notify for one operation.
705 Plan 257 P1 made this the seam; plan 270 P3 inverted its default.
706 ``None`` and an empty sequence now mean the same thing -- write the
707 status file and reach nothing else -- because a standalone package
708 has no registry to mirror into and must not invent one. The
709 consumer that HAS a registry passes its observer in; ``oc_chat``'s
710 ``scripts/plan_status.py`` shim passes ``RegistryMirrorObserver()``
711 on every command, so its behaviour is unchanged.
713 This is the line plan 257 called "the one line a split replaces",
714 and this is the split that replaced it: nothing in this package
715 names a registry any more.
717 func =
"plan_status._resolve_observers"
718 if observers
is None:
719 _dbg(
"OC_CHAT.MAIN", func,
"no observers registered", 4)
721 return tuple(observers)
725 observers: Sequence[StateObserver] |
None,
726 emit: Callable[[StateObserver], int],
728 """Run *emit* over every observer; the first non-zero code wins.
730 A consumer typically registers exactly one observer, so this
731 reproduces the single return code the pre-257 ``_mirror_*`` helpers
732 produced. A non-zero code aborts the command, exactly as the
733 mirror's ``2`` did. With no observers registered there is nothing to
734 run and the answer is ``0``: writing the status file succeeded and
735 nobody asked to be told.
737 func =
"plan_status._notify"
739 code = emit(observer)
741 _dbg(
"OC_CHAT.MAIN", func, f
"observer reported code {code}", 2)
748 for line
in body.splitlines():
749 stripped = line.strip()
756 for value
in (status.finished_at, status.started_at, status.queued_at):
757 if value
is not None:
758 return value.isoformat()
765 observers: Sequence[StateObserver] |
None =
None,
767 """Announce that *status* was initialised for its plan."""
769 worktree = Path(status.worktree)
773 lambda observer: observer.plan_created(
774 plan_id=status.plan_id,
775 title=str(metadata[
"title"]),
776 plan_type=metadata[
"plan_type"],
777 body_path=str(metadata[
"body_path"]),
778 body_sha256=str(metadata[
"body_sha256"]),
781 thinking=status.thinking,
782 session=status.session,
783 branch=status.branch,
784 worktree=status.worktree,
785 target_status=target,
795 observers: Sequence[StateObserver] |
None =
None,
797 """Announce that the plan moved from *previous* to *current*.
799 A transition that does not change the REGISTRY-side spelling of the
800 status is not an event: ``Implementing`` and ``Blocked`` both map to
801 ``implementing``, so no observer is notified and nothing is reached.
807 if target
in {
"partial",
"failed"}:
808 note =
_body_note(body, f
"legacy status transition to {current.status.value}")
811 lambda observer: observer.status_changed(
812 plan_id=current.plan_id,
821 args: argparse.Namespace,
823 observers: Sequence[StateObserver] |
None =
None,
824 lookup: AgentLookup |
None =
None,
826 func =
"plan_status._cmd_init"
828 target_status =
Status(args.status)
830 (lookup
or _default_agent_lookup)(args.agent_registry, args.agent)
831 except AgentLookupError
as exc:
832 print(f
"agent validation failed: {exc}", file=sys.stderr)
836 schema_version=SCHEMA_VERSION,
842 thinking=args.thinking,
843 session=args.session,
844 status=target_status.value,
846 if target_status == Status.QUEUED:
847 fields[
"queued_at"] = now_iso
848 elif target_status == Status.IMPLEMENTING:
849 fields[
"started_at"] = now_iso
855 print(f
"Initialised {path} (status={target_status.value}).")
856 _dbg(
"OC_CHAT.MAIN", func, f
"init {path} ({target_status.value})", 2)
861 args: argparse.Namespace,
863 observers: Sequence[StateObserver] |
None =
None,
865 func =
"plan_status._cmd_set"
867 body: str |
None =
None
869 body = Path(args.body_file).read_text(encoding=
"utf-8")
871 previous, _previous_body =
read(path)
874 previous.status, result, body, observers=observers
878 print(f
"{path}: status -> {result.status.value}")
879 progress_updates: dict[str, object] = {}
880 if args.current_step
is not None:
881 progress_updates[
"current_step"] = args.current_step
882 if args.steps_done
is not None:
883 progress_updates[
"steps_done"] = args.steps_done
884 if args.steps_total
is not None:
885 progress_updates[
"steps_total"] = args.steps_total
887 result =
update(path, progress=progress_updates)
888 print(f
"{path}: progress -> {result.progress}")
889 if not args.status
and not progress_updates
and body
is not None:
891 print(f
"{path}: body updated")
892 if args.status
and result.status
is Status.BLOCKED:
899 _dbg(
"OC_CHAT.MAIN", func, f
"set {path}", 2)
904 args: argparse.Namespace, current: PlanStatus, body: str |
None
906 """Write one ``blocked`` lane event for *current*; warn, never fail."""
907 func =
"plan_status._emit_blocked_event"
910 step = getattr(args,
"current_step",
None)
913 if body
and body.strip()
915 if step
and step.strip()
916 else f
"{current.plan_id} entered Blocked; no reason was given on the transition."
918 worktree = Path(current.worktree)
919 now = datetime.now(timezone.utc).replace(microsecond=0)
922 plan_id=current.plan_id,
926 report_path=worktree / DEFAULT_REPORT_FILENAME,
931 Path(args.comms_root).expanduser().resolve()
932 if getattr(args,
"comms_root",
None)
936 except (WorkerReportError, OSError)
as exc:
938 f
"warning: worker report for Blocked not written ({exc}); "
939 f
"the status file is updated regardless",
942 _dbg(
"OC_CHAT.MAIN", func, f
"EXTERNAL_ERROR: blocked event not written: {exc}", 2)
944 print(f
"Wrote worker report {event['event_id']} (Blocked) to {written}.")
945 _dbg(
"OC_CHAT.MAIN", func, f
"blocked event {current.plan_id} -> {written}", 2)
949 func =
"plan_status._cmd_show"
951 status, _body =
read(path)
952 progress_text =
"?/?"
953 if status.progress
is not None:
954 done = status.progress.steps_done
if status.progress.steps_done
is not None else "?"
955 total = status.progress.steps_total
if status.progress.steps_total
is not None else "?"
956 progress_text = f
"{done}/{total}"
957 started_text = status.started_at.isoformat()
if status.started_at
else "(not started)"
958 print(f
"{status.plan_id} | {status.status.value} | {progress_text} | started {started_text}")
959 _dbg(
"OC_CHAT.MAIN", func, f
"show {path}", 2)
964 if args.summary_file:
965 return Path(args.summary_file).read_text(encoding=
"utf-8")
966 return args.summary
or ""
970 func =
"plan_status._cmd_report"
973 Path(args.report_path).expanduser().resolve()
975 else worktree / DEFAULT_REPORT_FILENAME
977 now = datetime.now(timezone.utc).replace(microsecond=0)
985 report_path=report_path,
990 Path(args.comms_root).expanduser().resolve()
995 except (WorkerReportError, OSError)
as exc:
996 print(f
"worker report refused: {exc}", file=sys.stderr)
999 f
"Wrote worker report {event['event_id']} "
1000 f
"({args.plan}, {args.agent}, {args.status}) to {path}."
1002 _dbg(
"OC_CHAT.MAIN", func, f
"report {args.plan} status={args.status}", 2)
1010DEFAULT_PROG =
"python -m oc_orchestrator.plan_status"
1014 argv: list[str] |
None =
None,
1016 prog: str = DEFAULT_PROG,
1017 observers: Sequence[StateObserver] |
None =
None,
1019 """Run one sub-command. 0 on success, non-zero on a named refusal.
1021 *prog* and *observers* are the consumer seams (plan 270 P4). Both
1022 default to this package standing alone: it names itself, and it
1023 tells nobody. ``oc_chat``'s shim passes its own module path and its
1024 registry mirror, which is what keeps every output and every registry
1025 row identical across the split.
1027 func =
"plan_status.main"
1028 parser = argparse.ArgumentParser(
1031 description=
"Read/write/transition .PLAN_STATUS.md lifecycle files.",
1033 sub = parser.add_subparsers(dest=
"sub", required=
True)
1035 p_init = sub.add_parser(
"init", help=
"Initialise a fresh .PLAN_STATUS.md")
1036 p_init.add_argument(
"--plan", required=
True, help=
"Plan id (filename stem)")
1037 p_init.add_argument(
"--branch", required=
True, help=
"Git branch name")
1038 p_init.add_argument(
1041 "Worktree absolute path written into the YAML field. "
1042 "Defaults to the resolved --worktree-path (cwd). "
1043 "Relative or mangled values are ignored — the cwd fallback is used "
1044 "instead so markdown-stripped paths from launch prompts don't poison the field."
1047 p_init.add_argument(
1050 type=_canonical_agent_id,
1053 "Registry-allocated instance identity (for example agent-001). "
1054 "The instance must exist and must not be closed."
1057 p_init.add_argument(
1060 "agents.sqlite path used to validate --agent "
1061 "(defaults to .option_c/orchestration/agents.sqlite from cwd)"
1064 p_init.add_argument(
"--model", required=
True, help=
"Provider-native model id")
1065 p_init.add_argument(
"--thinking", required=
True, choices=[
"off",
"low",
"medium",
"high",
"max"])
1066 p_init.add_argument(
"--session", required=
True, choices=[
"fresh",
"continue",
"either"])
1067 p_init.add_argument(
"--status", default=
"Implementing", choices=[s.value
for s
in Status])
1068 p_init.add_argument(
"--path", help=
"Override target .PLAN_STATUS.md path")
1069 p_init.add_argument(
"--worktree-path", help=
"Worktree directory (default: cwd)")
1071 p_set = sub.add_parser(
"set", help=
"Update status / progress / body of an existing file")
1072 p_set.add_argument(
"--status", choices=[s.value
for s
in Status])
1073 p_set.add_argument(
"--current-step", help=
"Set progress.current_step")
1074 p_set.add_argument(
"--steps-done", type=int, help=
"Set progress.steps_done")
1075 p_set.add_argument(
"--steps-total", type=int, help=
"Set progress.steps_total")
1076 p_set.add_argument(
"--body-file", help=
"Read Markdown body from FILE")
1077 p_set.add_argument(
"--path", help=
"Override target .PLAN_STATUS.md path")
1078 p_set.add_argument(
"--worktree-path", help=
"Worktree directory (default: cwd)")
1081 help=
"The recorded worktree root; locates the status file when neither "
1082 "--path nor --worktree-path is given (the launch brief runs you from "
1083 "<worktree>/oc_chat)",
1088 "Comms-bus root for the Blocked lane event (default: discovered from "
1089 "the status file's worktree); a Blocked transition emits one event"
1093 p_show = sub.add_parser(
"show", help=
"Print a one-line summary of the status file")
1094 p_show.add_argument(
"--path", help=
"Override target .PLAN_STATUS.md path")
1095 p_show.add_argument(
"--worktree-path", help=
"Worktree directory (default: cwd)")
1096 p_show.add_argument(
1098 help=
"The recorded worktree root; locates the status file when neither "
1099 "--path nor --worktree-path is given",
1102 p_report = sub.add_parser(
1104 help=
"Post this worker's closing summary to the comms worker-report lane",
1106 p_report.add_argument(
"--plan", required=
True, help=
"Plan id (filename stem)")
1107 p_report.add_argument(
1110 type=_canonical_agent_id,
1112 help=
"Registry-allocated instance identity (for example agent-001)",
1114 p_report.add_argument(
1117 choices=list(WORKER_REPORT_STATUSES),
1118 help=
"Both statuses emit an event (operator ruling, proposal V1 §10.3)",
1120 summary_source = p_report.add_mutually_exclusive_group(required=
True)
1121 summary_source.add_argument(
1123 help=
"Read the FULL closing summary from FILE (normally report.md)",
1125 summary_source.add_argument(
1127 help=
"The FULL closing summary as a literal argument",
1129 p_report.add_argument(
1132 "Absolute path of report.md in this worktree "
1133 f
"(default: <worktree>/{DEFAULT_REPORT_FILENAME})"
1136 p_report.add_argument(
1138 help=
"Worktree absolute path recorded in the event (default: cwd)",
1140 p_report.add_argument(
"--worktree-path", help=
"Worktree directory (default: cwd)")
1141 p_report.add_argument(
1144 "Comms bus root to write into. Defaults to the workspace-anchored "
1145 "root discovered above the worktree; pass it explicitly when the "
1146 "worktree's own workspace marker is not the bus the orchestrator reads."
1150 args = parser.parse_args(argv)
1151 _dbg(
"OC_CHAT.MAIN", func, f
"dispatch sub-command: {args.sub}", 2)
1152 if args.sub ==
"init":
1153 return _cmd_init(args, observers=observers)
1154 if args.sub ==
"set":
1155 return _cmd_set(args, observers=observers)
1156 if args.sub ==
"show":
1158 if args.sub ==
"report":
1160 parser.error(f
"unknown sub-command: {args.sub}")
1164if __name__ ==
"__main__":
str _check_schema_version(cls, str value)
"PlanStatus" _check_timestamp_order(self)
"PlanStatus" _check_agent_identity(self)
str _canonical_agent_id(str value)
int _cmd_init(argparse.Namespace args, *, Sequence[StateObserver]|None observers=None, AgentLookup|None lookup=None)
dict[str, str|None] _plan_metadata(str plan_id, Path worktree, *, PlanMetadataProvider|None provider=None, PlanPaths|None paths=None)
int _mirror_init(PlanStatus status, *, Sequence[StateObserver]|None observers=None)
str new_ulid(datetime now, *, bytes|None entropy=None)
str utc_stamp(datetime now)
None _emit_blocked_event(argparse.Namespace args, PlanStatus current, str|None body)
str _resolve_summary(argparse.Namespace args)
str _utc_timestamp(datetime now)
str _default_agent_id_validator(str value)
int _cmd_set(argparse.Namespace args, *, Sequence[StateObserver]|None observers=None)
int _cmd_report(argparse.Namespace args)
str _encode(int value, int length)
dict[str, object] worker_report_event(*, str plan_id, str agent, str status, Path worktree, Path report_path, str summary, datetime now, bytes|None entropy=None)
AgentIdValidator _agent_id_validator
int _mirror_transition(Status previous, PlanStatus current, str|None body, *, Sequence[StateObserver]|None observers=None)
int _notify(Sequence[StateObserver]|None observers, Callable[[StateObserver], int] emit)
None _default_agent_lookup(str|None registry_arg, str agent_id)
str|None _status_observed_at(PlanStatus status)
Path _resolve_worktree_field(argparse.Namespace args)
Path write_worker_report(Path comms_root, dict[str, object] event, *, datetime now)
str _body_note(str|None body, str fallback)
int _cmd_show(argparse.Namespace args)
int main(list[str]|None argv=None, *, str prog=DEFAULT_PROG, Sequence[StateObserver]|None observers=None)
Path find_comms_root(Path start)
None _validate_transition(Status|None current, Status target)
tuple[PlanStatus, str] read(Path path)
PlanStatus update(Path path, str|None body=None, **object fields)
str _emit_frontmatter(PlanStatus status)
str _registry_status(Status status)
tuple[StateObserver,...] _resolve_observers(Sequence[StateObserver]|None observers)
PlanStatus transition(Path path, Status new_status, str|None body=None)
None write(Path path, PlanStatus status, str body="")
int _plan_number(str plan_id)
Path _resolve_path(argparse.Namespace args)