oc_orchestrator
Loading...
Searching...
No Matches
plan_status.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# -*- coding: utf-8 -*-
3# oc_orchestrator/plan_status.py
4
5# OCT-LINT: disable=tests_exist reason="Covered by tests/test_plan_status.py, tests/test_221_plan_status.py and tests/test_270_standalone_dispatch.py"
6"""
7Per-worktree ``.PLAN_STATUS.md`` lifecycle helper + CLI.
8
9Purpose
10-------
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``).
16
17Responsibilities
18----------------
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.
28
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.
39
40Diagnostics
41-----------
42Domain: OC_CHAT.MAIN
43Levels:
44 L2 - lifecycle events (read, write, transition)
45 L3 - semantic details (invalid transitions, validation errors)
46 L4 - deep tracing (per-field parse / emit)
47
48Contracts
49---------
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).
58
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.
71
72Dependencies
73------------
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)
81"""
82from __future__ import annotations
83
84import argparse
85import hashlib
86import json
87import secrets
88import sys
89import tempfile
90from datetime import datetime, timezone
91from enum import Enum
92from pathlib import Path
93from typing import Callable, Literal, Sequence
94
95import yaml
96from pydantic import BaseModel, ConfigDict, field_validator, model_validator
97
98from oc_diagnostics import _dbg
99
100from oc_orchestrator.plan_inventory import PlanPaths
102 PlanMetadataProvider,
103 resolve_provider,
104 split_frontmatter,
105)
106from oc_orchestrator.state_observer import StateObserver
107
108
109_dbg("OC_CHAT.MAIN", "module", "loaded", 4)
110
111
112SCHEMA_VERSION = "2.0"
113LEGACY_SCHEMA_VERSION = "1.0"
114DEFAULT_FILENAME = ".PLAN_STATUS.md"
115
116# ── Worker-report lane constants (plan 231; proposal V1 §3 and §5) ───────
117# These mirror oc_connect's own values on purpose. The mirror is what buys
118# the worker its credential-free, dependency-free write path; the drift risk
119# it creates is paid for by a cross-implementation test that generates the
120# same identifier from both sides and compares the bytes.
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"
130
131#: Crockford base32, the ULID alphabet: no I, L, O or U.
132# OCT-LINT: disable=no_hardcoded_secrets reason="Public standard encoding alphabet; its 5.00 bits/char is the arithmetic maximum for any 32-distinct-character string and carries no credential. Operator-ruled 2026-09-01 (plan 230 W1 escalation), same constant as oc_connect.comms"
133_CROCKFORD = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"
134
135
136# ── Agent-identity seams (plan 257 P4) ───────────────────────────────────
137#
138# Two separate things wear the name "agent validation" here, and only one
139# of them touches a store. The FORMAT check is a pure regex; the LIVE
140# check opens the runtime instance registry. Both are injectable, so this
141# module names ``oc_orchestrator.agent_instances`` only inside the two
142# defaults, and a consumer with a roster instead of a registry can supply
143# its own without touching the dispatch layer.
144
145
146class AgentLookupError(RuntimeError):
147 """A refusal from the injected live-instance lookup."""
148
149
150#: ``(agent_registry_argument, agent_id) -> None``; raises to refuse.
151AgentLookup = Callable[[str | None, str], None]
152
153#: ``(value) -> canonical value``; raises :class:`ValueError` to refuse.
154AgentIdValidator = Callable[[str], str]
155
156
157def _default_agent_id_validator(value: str) -> str:
158 """Today's format check: ``agent_instances.validate_agent_id``."""
159 from oc_orchestrator.agent_instances import validate_agent_id
160
161 return validate_agent_id(value)
162
163
164def _default_agent_lookup(registry_arg: str | None, agent_id: str) -> None:
165 """Today's live check: the instance must exist and not be closed.
166
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.
173 """
174 func = "plan_status._default_agent_lookup"
176 AgentInstanceRegistry,
177 AgentRegistryError,
178 default_agent_registry,
179 )
180
181 registry_path = (
182 Path(registry_arg) if registry_arg else default_agent_registry(Path.cwd())
183 )
184 try:
185 AgentInstanceRegistry(registry_path).require_live(agent_id)
186 except AgentRegistryError as exc:
187 # VALIDATION_ERROR: an unknown or closed instance is an expected,
188 # handled refusal; the command reports it and returns 1.
189 _dbg("OC_CHAT.MAIN", func, f"VALIDATION_ERROR: {exc}", 3)
190 raise AgentLookupError(str(exc)) from exc
191
192
193#: Read at call time, never captured, so a substitution is honoured by
194#: both the pydantic model validator and the argparse type.
195_agent_id_validator: AgentIdValidator = _default_agent_id_validator
196
197
198# ── Public types ─────────────────────────────────────────────────────────
199
200
201class Status(str, Enum):
202 """Lifecycle status of a plan in a worktree."""
203
204 QUEUED = "Queued"
205 IMPLEMENTING = "Implementing"
206 COMPLETED = "Completed"
207 BLOCKED = "Blocked"
208 PARTIALLY_COMPLETED = "Partially Completed"
209 FAILED = "Failed"
210
211
212TERMINAL_STATUSES: frozenset[Status] = frozenset(
213 {Status.COMPLETED, Status.PARTIALLY_COMPLETED, Status.FAILED}
214)
215
216
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(),
225}
226
227
228class Progress(BaseModel):
229 model_config = ConfigDict(extra="forbid")
230 steps_total: int | None = None
231 steps_done: int | None = None
232 current_step: str | None = None
233
234
235class AcceptanceChecks(BaseModel):
236 model_config = ConfigDict(extra="forbid")
237 total: int
238 passed: int
239 failed: int
240 remaining: int
241
242
243class PlanStatus(BaseModel):
244 """Validated `.PLAN_STATUS.md` frontmatter (schema v1.0)."""
245
246 model_config = ConfigDict(extra="forbid", use_enum_values=False)
247
248 schema_version: str = SCHEMA_VERSION
249 plan_id: str
250 branch: str
251 worktree: str
252 agent: str
253 model: str
254 thinking: Literal["off", "low", "medium", "high", "max"]
255 session: Literal["fresh", "continue", "either"]
256 status: Status
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
262
263 @field_validator("schema_version")
264 @classmethod
265 def _check_schema_version(cls, value: str) -> str:
266 if value not in {LEGACY_SCHEMA_VERSION, SCHEMA_VERSION}:
267 raise ValueError(
268 f"unsupported schema_version {value!r}; this helper reads "
269 f"{LEGACY_SCHEMA_VERSION!r} and {SCHEMA_VERSION!r}"
270 )
271 return value
272
273 @model_validator(mode="after")
274 def _check_agent_identity(self) -> "PlanStatus":
275 if self.schema_version == LEGACY_SCHEMA_VERSION:
276 if not self.agent or not self.agent.isalpha() or not self.agent.isupper():
277 raise ValueError(
278 "legacy schema agent must be uppercase letters, "
279 f"got {self.agent!r}"
280 )
281 else:
282 try:
284 except ValueError as exc:
285 raise ValueError(str(exc)) from exc
286 return self
287
288 @model_validator(mode="after")
289 def _check_timestamp_order(self) -> "PlanStatus":
290 ordered = [
291 ("queued_at", self.queued_at),
292 ("started_at", self.started_at),
293 ("finished_at", self.finished_at),
294 ]
295 prev_name, prev_ts = None, None
296 for name, ts in ordered:
297 if ts is None:
298 continue
299 if prev_ts is not None and ts < prev_ts:
300 raise ValueError(
301 f"timestamp order violated: {name}={ts!r} precedes "
302 f"{prev_name}={prev_ts!r}"
303 )
304 prev_name, prev_ts = name, ts
305 return self
306
307
308# ── Frontmatter parse / emit ─────────────────────────────────────────────
309
310
311def _now_local() -> datetime:
312 # TZ-aware local datetime with explicit offset; emitted via
313 # .isoformat() gives e.g. '2026-05-17T00:25:42+02:00'.
314 # Per CHANGELOG-format.md §"Timestamps".
315 return datetime.now().astimezone().replace(microsecond=0)
316
317
318#: The ``---`` fence split now lives beside the other plan-header
319#: parsing (plan 257 P2). The private name stays bound here because
320#: ``scripts/plan_manage.py`` and ``registry/migrate.py`` both reach it
321#: as ``plan_status._split_frontmatter``.
322_split_frontmatter = split_frontmatter
323
324
325def _emit_frontmatter(status: PlanStatus) -> str:
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)
329
330
331# ── Public functions ─────────────────────────────────────────────────────
332
333
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")
338 frontmatter, body = _split_frontmatter(text)
339 data = yaml.safe_load(frontmatter)
340 if not isinstance(data, dict):
341 raise ValueError("frontmatter must be a YAML mapping")
342 status = PlanStatus(**data)
343 _dbg("OC_CHAT.MAIN", func, f"read {path} status={status.status.value}", 2)
344 return status, body
345
346
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:
351 raise ValueError(
352 f"schema {status.schema_version!r} is read-only; "
353 f"new writes require {SCHEMA_VERSION!r}"
354 )
355 path = Path(path)
356 frontmatter = _emit_frontmatter(status)
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(
360 mode="w",
361 encoding="utf-8",
362 dir=path.parent,
363 prefix=".plan_status_",
364 suffix=".tmp",
365 delete=False,
366 ) as tmp:
367 tmp.write(payload)
368 tmp_path = Path(tmp.name)
369 tmp_path.replace(path)
370 _dbg("OC_CHAT.MAIN", func, f"wrote {path} ({len(payload)}B)", 2)
371
372
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:
378 _validate_transition(current.status, Status(fields["status"]))
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"}
385 data.update(fields)
386 new_status = PlanStatus(**data)
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)
389 return new_status
390
391
392def transition(path: Path, new_status: Status, body: str | None = None) -> PlanStatus:
393 """Transition the status file at *path* to *new_status*.
394
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.
398 """
399 func = "plan_status.transition"
400 current, current_body = read(path)
401 _validate_transition(current.status, new_status)
402 data = current.model_dump(mode="json")
403 data["status"] = new_status.value
404 now_iso = _now_local().isoformat()
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
411 new_status_obj = PlanStatus(**data)
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
415
416
417def _validate_transition(current: Status | None, target: Status) -> None:
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)
422 raise ValueError(
423 f"illegal status transition: {current.value if current else 'None'} "
424 f"→ {target.value}"
425 )
426
427
428# ── Worker-report lane (plan 231) ────────────────────────────────────────
429
430
431class WorkerReportError(ValueError):
432 """A worker-report event could not be written as specified."""
433
434
435class EmptySummaryError(WorkerReportError):
436 """The worker's closing summary was blank, so there is nothing to relay."""
437
438
440 """No workspace or project marker was found above the starting path."""
441
442
443def _encode(value: int, length: int) -> str:
444 digits = []
445 for _ in range(length):
446 value, remainder = divmod(value, 32)
447 digits.append(_CROCKFORD[remainder])
448 return "".join(reversed(digits))
449
450
451def new_ulid(now: datetime, *, entropy: bytes | None = None) -> str:
452 """Return a 26-character ULID: 48 bits of time, 80 bits of randomness.
453
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.
458 """
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)
463 if len(raw) != 10:
464 raise ValueError("ULID entropy must be exactly ten bytes")
465 return _encode(milliseconds, 10) + _encode(int.from_bytes(raw, "big"), 16)
466
467
468def utc_stamp(now: datetime) -> str:
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")
473
474
475def _utc_timestamp(now: datetime) -> str:
476 if now.tzinfo is None:
477 now = now.replace(tzinfo=timezone.utc)
478 return now.astimezone(timezone.utc).isoformat().replace("+00:00", "Z")
479
480
481def find_comms_root(start: Path) -> Path:
482 """Return the workspace-anchored comms-bus root above *start*.
483
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.
490 """
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():
496 anchor = parent
497 break
498 if project_fallback is None and (parent / PROJECT_MARKER).is_dir():
499 project_fallback = parent
500 if anchor is None:
501 anchor = project_fallback
502 if anchor is None:
504 f"no Option C workspace marker ({WORKSPACE_MARKER}) or project "
505 f"marker ({PROJECT_MARKER}/) was found at or above {origin}"
506 )
507 if (anchor / COMMS_ANCHOR).is_dir():
508 return anchor.joinpath(COMMS_ANCHOR, *COMMS_SUBPATH)
509 return anchor.joinpath(*COMMS_SUBPATH)
510
511
513 *,
514 plan_id: str,
515 agent: str,
516 status: str,
517 worktree: Path,
518 report_path: Path,
519 summary: str,
520 now: datetime,
521 entropy: bytes | None = None,
522) -> dict[str, object]:
523 """Build one lane event; refuse by name when the summary is empty.
524
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.
529 """
530 if status not in WORKER_REPORT_STATUSES:
531 raise WorkerReportError(
532 f"worker report status must be one of "
533 f"{', '.join(WORKER_REPORT_STATUSES)}; got {status!r}"
534 )
535 if not summary.strip():
536 raise EmptySummaryError(
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"
539 )
540 return {
541 "schema_version": WORKER_REPORT_SCHEMA_VERSION,
542 "event_id": new_ulid(now, entropy=entropy),
543 "lane": WORKER_REPORT_LANE,
544 "direction": "inbound",
545 "risk_class": "low",
546 "plan_id": plan_id,
547 "agent": agent,
548 "status": status,
549 "worktree": str(worktree),
550 "report_path": str(report_path),
551 "created_at_utc": _utc_timestamp(now),
552 "summary": summary,
553 "summary_sha256": hashlib.sha256(summary.encode("utf-8")).hexdigest(),
554 "relayed_at_utc": None,
555 "relay_chunk_count": None,
556 }
557
558
560 comms_root: Path,
561 event: dict[str, object],
562 *,
563 now: datetime,
564) -> Path:
565 """Write one event into ``lanes/worker-reports/`` and return its path.
566
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.
570 """
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
576 if path.exists():
577 raise WorkerReportError(
578 f"refusing to overwrite an existing worker report event at {path}"
579 )
580 payload = json.dumps(event, indent=2, sort_keys=True) + "\n"
581 with tempfile.NamedTemporaryFile(
582 mode="w",
583 encoding="utf-8",
584 newline="\n",
585 dir=lane_dir,
586 prefix=".worker_report_",
587 suffix=".tmp",
588 delete=False,
589 ) as tmp:
590 tmp.write(payload)
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)
594 return path
595
596
597# ── CLI ──────────────────────────────────────────────────────────────────
598
599
600def _canonical_agent_id(value: str) -> str:
601 """Argparse type for the registry-allocated ``agent-NNN`` form."""
602 try:
603 return _agent_id_validator(value)
604 except ValueError as exc:
605 raise argparse.ArgumentTypeError(str(exc)) from exc
606
607
608def _resolve_path(args: argparse.Namespace) -> Path:
609 """Where the status file lives: ``--path`` > ``--worktree-path`` >
610 the recorded absolute ``--worktree`` > cwd.
611
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.
617 """
618 if getattr(args, "path", None):
619 return Path(args.path)
620 explicit = getattr(args, "worktree_path", None)
621 if explicit:
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
627
628
629def _resolve_worktree_field(args: argparse.Namespace) -> Path:
630 raw = getattr(args, "worktree", None)
631 if raw:
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()
636
637
638def _registry_status(status: Status) -> str:
639 return {
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",
646 }[status]
647
648
649def _plan_number(plan_id: str) -> int:
650 prefix = plan_id.split("-", 1)[0]
651 try:
652 value = int(prefix)
653 except ValueError as exc:
654 raise ValueError(f"plan id {plan_id!r} does not start with a number") from exc
655 if value <= 0:
656 raise ValueError(f"plan id {plan_id!r} does not start with a positive number")
657 return value
658
659
661 plan_id: str,
662 worktree: Path,
663 *,
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.
668
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.
672
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.
677 """
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)
683 return {
684 "title": plan_id,
685 "body_path": relative_path.as_posix(),
686 "body_sha256": hashlib.sha256(b"").hexdigest(),
687 "plan_type": None,
688 }
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)
692 return {
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,
697 }
698
699
701 observers: Sequence[StateObserver] | None,
702) -> tuple[StateObserver, ...]:
703 """The observers to notify for one operation.
704
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.
712
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.
716 """
717 func = "plan_status._resolve_observers"
718 if observers is None:
719 _dbg("OC_CHAT.MAIN", func, "no observers registered", 4)
720 return ()
721 return tuple(observers)
722
723
725 observers: Sequence[StateObserver] | None,
726 emit: Callable[[StateObserver], int],
727) -> int:
728 """Run *emit* over every observer; the first non-zero code wins.
729
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.
736 """
737 func = "plan_status._notify"
738 for observer in _resolve_observers(observers):
739 code = emit(observer)
740 if code:
741 _dbg("OC_CHAT.MAIN", func, f"observer reported code {code}", 2)
742 return code
743 return 0
744
745
746def _body_note(body: str | None, fallback: str) -> str:
747 if body:
748 for line in body.splitlines():
749 stripped = line.strip()
750 if stripped:
751 return stripped
752 return fallback
753
754
755def _status_observed_at(status: PlanStatus) -> str | None:
756 for value in (status.finished_at, status.started_at, status.queued_at):
757 if value is not None:
758 return value.isoformat()
759 return None
760
761
763 status: PlanStatus,
764 *,
765 observers: Sequence[StateObserver] | None = None,
766) -> int:
767 """Announce that *status* was initialised for its plan."""
768 target = _registry_status(status.status)
769 worktree = Path(status.worktree)
770 metadata = _plan_metadata(status.plan_id, worktree)
771 return _notify(
772 observers,
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"]),
779 body_observed_at=_status_observed_at(status),
780 model=status.model,
781 thinking=status.thinking,
782 session=status.session,
783 branch=status.branch,
784 worktree=status.worktree,
785 target_status=target,
786 ),
787 )
788
789
791 previous: Status,
792 current: PlanStatus,
793 body: str | None,
794 *,
795 observers: Sequence[StateObserver] | None = None,
796) -> int:
797 """Announce that the plan moved from *previous* to *current*.
798
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.
802 """
803 target = _registry_status(current.status)
804 if target == _registry_status(previous):
805 return 0
806 note = None
807 if target in {"partial", "failed"}:
808 note = _body_note(body, f"legacy status transition to {current.status.value}")
809 return _notify(
810 observers,
811 lambda observer: observer.status_changed(
812 plan_id=current.plan_id,
813 from_status=_registry_status(previous),
814 to_status=target,
815 note=note,
816 ),
817 )
818
819
821 args: argparse.Namespace,
822 *,
823 observers: Sequence[StateObserver] | None = None,
824 lookup: AgentLookup | None = None,
825) -> int:
826 func = "plan_status._cmd_init"
827 path = _resolve_path(args)
828 target_status = Status(args.status)
829 try:
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)
833 return 1
834 now_iso = _now_local().isoformat()
835 fields = dict(
836 schema_version=SCHEMA_VERSION,
837 plan_id=args.plan,
838 branch=args.branch,
839 worktree=str(_resolve_worktree_field(args)),
840 agent=args.agent,
841 model=args.model,
842 thinking=args.thinking,
843 session=args.session,
844 status=target_status.value,
845 )
846 if target_status == Status.QUEUED:
847 fields["queued_at"] = now_iso
848 elif target_status == Status.IMPLEMENTING:
849 fields["started_at"] = now_iso
850 status = PlanStatus(**fields)
851 write(path, status)
852 mirror_rc = _mirror_init(status, observers=observers)
853 if mirror_rc:
854 return mirror_rc
855 print(f"Initialised {path} (status={target_status.value}).")
856 _dbg("OC_CHAT.MAIN", func, f"init {path} ({target_status.value})", 2)
857 return 0
858
859
861 args: argparse.Namespace,
862 *,
863 observers: Sequence[StateObserver] | None = None,
864) -> int:
865 func = "plan_status._cmd_set"
866 path = _resolve_path(args)
867 body: str | None = None
868 if args.body_file:
869 body = Path(args.body_file).read_text(encoding="utf-8")
870 if args.status:
871 previous, _previous_body = read(path)
872 result = transition(path, Status(args.status), body=body)
873 mirror_rc = _mirror_transition(
874 previous.status, result, body, observers=observers
875 )
876 if mirror_rc:
877 return mirror_rc
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
886 if progress_updates:
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:
890 update(path, body=body)
891 print(f"{path}: body updated")
892 if args.status and result.status is Status.BLOCKED:
893 # Blocked is the moment the operator must act, so it is heard on the
894 # worker-report lane without the worker knowing the lane exists
895 # (operator ruling 2026-09-04: a Blocked worker was silent because the
896 # lane only fired at AWAITING GATE). Fail-SOFT by design: recording
897 # Blocked must never depend on the comms bus being reachable.
898 _emit_blocked_event(args, result, body)
899 _dbg("OC_CHAT.MAIN", func, f"set {path}", 2)
900 return 0
901
902
904 args: argparse.Namespace, current: PlanStatus, body: str | None
905) -> None:
906 """Write one ``blocked`` lane event for *current*; warn, never fail."""
907 func = "plan_status._emit_blocked_event"
908 # Only a reason given ON THIS transition counts: a stale progress step from
909 # an earlier call is not why the worker is blocked now.
910 step = getattr(args, "current_step", None)
911 summary = (
912 body
913 if body and body.strip()
914 else step
915 if step and step.strip()
916 else f"{current.plan_id} entered Blocked; no reason was given on the transition."
917 )
918 worktree = Path(current.worktree)
919 now = datetime.now(timezone.utc).replace(microsecond=0)
920 try:
921 event = worker_report_event(
922 plan_id=current.plan_id,
923 agent=current.agent,
924 status="blocked",
925 worktree=worktree,
926 report_path=worktree / DEFAULT_REPORT_FILENAME,
927 summary=summary,
928 now=now,
929 )
930 comms_root = (
931 Path(args.comms_root).expanduser().resolve()
932 if getattr(args, "comms_root", None)
933 else find_comms_root(worktree)
934 )
935 written = write_worker_report(comms_root, event, now=now)
936 except (WorkerReportError, OSError) as exc:
937 print(
938 f"warning: worker report for Blocked not written ({exc}); "
939 f"the status file is updated regardless",
940 file=sys.stderr,
941 )
942 _dbg("OC_CHAT.MAIN", func, f"EXTERNAL_ERROR: blocked event not written: {exc}", 2)
943 return
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)
946
947
948def _cmd_show(args: argparse.Namespace) -> int:
949 func = "plan_status._cmd_show"
950 path = _resolve_path(args)
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)
960 return 0
961
962
963def _resolve_summary(args: argparse.Namespace) -> str:
964 if args.summary_file:
965 return Path(args.summary_file).read_text(encoding="utf-8")
966 return args.summary or ""
967
968
969def _cmd_report(args: argparse.Namespace) -> int:
970 func = "plan_status._cmd_report"
971 worktree = _resolve_worktree_field(args)
972 report_path = (
973 Path(args.report_path).expanduser().resolve()
974 if args.report_path
975 else worktree / DEFAULT_REPORT_FILENAME
976 )
977 now = datetime.now(timezone.utc).replace(microsecond=0)
978 try:
979 summary = _resolve_summary(args)
980 event = worker_report_event(
981 plan_id=args.plan,
982 agent=args.agent,
983 status=args.status,
984 worktree=worktree,
985 report_path=report_path,
986 summary=summary,
987 now=now,
988 )
989 comms_root = (
990 Path(args.comms_root).expanduser().resolve()
991 if args.comms_root
992 else find_comms_root(worktree)
993 )
994 path = write_worker_report(comms_root, event, now=now)
995 except (WorkerReportError, OSError) as exc:
996 print(f"worker report refused: {exc}", file=sys.stderr)
997 return 2
998 print(
999 f"Wrote worker report {event['event_id']} "
1000 f"({args.plan}, {args.agent}, {args.status}) to {path}."
1001 )
1002 _dbg("OC_CHAT.MAIN", func, f"report {args.plan} status={args.status}", 2)
1003 return 0
1004
1005
1006#: How this CLI names itself in ``--help``. A consumer that exposes the
1007#: tool under its own module path passes its own (plan 270 P4): the
1008#: shim's help output must stay byte-identical to what it printed before
1009#: the split, and ``prog`` is the only line in it that could differ.
1010DEFAULT_PROG = "python -m oc_orchestrator.plan_status"
1011
1012
1014 argv: list[str] | None = None,
1015 *,
1016 prog: str = DEFAULT_PROG,
1017 observers: Sequence[StateObserver] | None = None,
1018) -> int:
1019 """Run one sub-command. 0 on success, non-zero on a named refusal.
1020
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.
1026 """
1027 func = "plan_status.main"
1028 parser = argparse.ArgumentParser(
1029 prog=prog,
1030 # OCT-LINT: disable=no_hardcoded_secrets reason="Status enum values, model IDs, and CLI choice strings are not secrets"
1031 description="Read/write/transition .PLAN_STATUS.md lifecycle files.",
1032 )
1033 sub = parser.add_subparsers(dest="sub", required=True)
1034
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(
1039 "--worktree",
1040 help=(
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."
1045 ),
1046 )
1047 p_init.add_argument(
1048 "--agent",
1049 required=True,
1050 type=_canonical_agent_id,
1051 metavar="AGENT_ID",
1052 help=(
1053 "Registry-allocated instance identity (for example agent-001). "
1054 "The instance must exist and must not be closed."
1055 ),
1056 )
1057 p_init.add_argument(
1058 "--agent-registry",
1059 help=(
1060 "agents.sqlite path used to validate --agent "
1061 "(defaults to .option_c/orchestration/agents.sqlite from cwd)"
1062 ),
1063 )
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)")
1070
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)")
1079 p_set.add_argument(
1080 "--worktree",
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)",
1084 )
1085 p_set.add_argument(
1086 "--comms-root",
1087 help=(
1088 "Comms-bus root for the Blocked lane event (default: discovered from "
1089 "the status file's worktree); a Blocked transition emits one event"
1090 ),
1091 )
1092
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(
1097 "--worktree",
1098 help="The recorded worktree root; locates the status file when neither "
1099 "--path nor --worktree-path is given",
1100 )
1101
1102 p_report = sub.add_parser(
1103 "report",
1104 help="Post this worker's closing summary to the comms worker-report lane",
1105 )
1106 p_report.add_argument("--plan", required=True, help="Plan id (filename stem)")
1107 p_report.add_argument(
1108 "--agent",
1109 required=True,
1110 type=_canonical_agent_id,
1111 metavar="AGENT_ID",
1112 help="Registry-allocated instance identity (for example agent-001)",
1113 )
1114 p_report.add_argument(
1115 "--status",
1116 required=True,
1117 choices=list(WORKER_REPORT_STATUSES),
1118 help="Both statuses emit an event (operator ruling, proposal V1 §10.3)",
1119 )
1120 summary_source = p_report.add_mutually_exclusive_group(required=True)
1121 summary_source.add_argument(
1122 "--summary-file",
1123 help="Read the FULL closing summary from FILE (normally report.md)",
1124 )
1125 summary_source.add_argument(
1126 "--summary",
1127 help="The FULL closing summary as a literal argument",
1128 )
1129 p_report.add_argument(
1130 "--report-path",
1131 help=(
1132 "Absolute path of report.md in this worktree "
1133 f"(default: <worktree>/{DEFAULT_REPORT_FILENAME})"
1134 ),
1135 )
1136 p_report.add_argument(
1137 "--worktree",
1138 help="Worktree absolute path recorded in the event (default: cwd)",
1139 )
1140 p_report.add_argument("--worktree-path", help="Worktree directory (default: cwd)")
1141 p_report.add_argument(
1142 "--comms-root",
1143 help=(
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."
1147 ),
1148 )
1149
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":
1157 return _cmd_show(args)
1158 if args.sub == "report":
1159 return _cmd_report(args)
1160 parser.error(f"unknown sub-command: {args.sub}")
1161 return 1
1162
1163
1164if __name__ == "__main__":
1165 sys.exit(main())
str _check_schema_version(cls, str value)
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)