oc_chat
Loading...
Searching...
No Matches
roadmap.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# -*- coding: utf-8 -*-
3# oc_chat/registry/cli/roadmap.py
4
5# OCT-LINT: disable=tests_exist reason="Covered by oc_chat/tests/registry/roadmap/test_cli.py"
6"""
7CLI commands for registry-backed ROADMAP data.
8
9Purpose
10-------
11Expose roadmap import, export, audit, bump, and status commands.
12
13Responsibilities
14----------------
15- Resolve registry and workspace paths through standard discovery.
16- Dispatch roadmap corpus ingest, artifact export, and drift audit helpers.
17- Dispatch the Plan 205 reconciliation check, which sits beside ``render
18 --check`` and answers a different question from it.
19- Emit compact human or JSON command output.
20
21Diagnostics
22-----------
23Domain: OC_CHAT.MAIN
24Levels:
25 L2 - CLI lifecycle
26 L3 - command argument summaries
27 L4 - subcommand dispatch details
28
29Contracts
30---------
31- Mutating bump operations route through the registry seam.
32- Export writes only to the requested output directory.
33
34Dependencies
35------------
36- argparse (CLI parsing)
37- oc_chat.registry.roadmap.reconcile (the Plan 205 claims-vs-state check)
38- oc_chat.registry.api (Registry facade over the live registry backend)
39- oc_chat.registry.backends.sqlite (SQLiteBackend for the live registry and the status counts)
40- oc_chat.registry.cli.source (source and hash-preimage subcommand wiring)
41- oc_chat.registry.discovery (registry and workspace path discovery)
42- oc_chat.registry.regenerate.envelope (error envelope and exit code for render failures)
43- oc_chat.registry.render.roadmap (render check and living set writer)
44- oc_chat.registry.roadmap.audit (audit_corpus for the audit command)
45- oc_chat.registry.roadmap.ingest (ingest_corpus for the import command)
46- oc_chat.registry.roadmap.operations (roadmap_version_bump for the bump command)
47- oc_diagnostics (structured debug logging via _dbg)
48"""
49from __future__ import annotations
50
51import argparse
52import json
53from pathlib import Path
54import sys
55
56from oc_diagnostics import _dbg
57
58from oc_chat.registry.api import Registry
59from oc_chat.registry.backends.sqlite import SQLiteBackend
60from oc_chat.registry.discovery import discover_registry_location
61from oc_chat.registry.roadmap.audit import audit_corpus
62from oc_chat.registry.roadmap.ingest import ingest_corpus
63from oc_chat.registry.roadmap.operations import roadmap_version_bump
65 NOT_DRIFT_BANNER,
66 JoinWriteResult,
67 ReconcileConfigError,
68 reconcile_workspace,
69 write_plan_links,
70)
71from oc_chat.registry.render.roadmap import render_check, write_living_set
73 RegenerateError,
74 SourceError,
75 build_envelope,
76 exit_code_for,
77)
78from oc_chat.registry.cli.source import add_source_subcommands
79
80_dbg("OC_CHAT.MAIN", "module", "loaded", 4)
81
82#: The round-close discipline — a declared ``advances:`` field on every plan and a
83#: status flip (or explicit ``none``) at the gate — landed 2026-08-14 with plan 205.
84#: The reconcile check grandfathers everything before it: plans 014-204 legitimately
85#: predate the ``advances:`` mechanism, and changelog records written before the rule
86#: cannot be held to it. These are the DEFAULTS for the two grandfather knobs, chosen
87#: so a bare ``reconcile`` reports today's actionable state, not ~525 historical
88#: not-applicables. Override (earlier date / lower plan id / empty) to audit history.
89_DISCIPLINE_LANDED = "2026-08-14"
90_ADVANCES_REQUIRED_FROM = "205"
91
92
93def add_roadmap_subcommands(subcommands: argparse._SubParsersAction) -> None:
94 """Attach the ``roadmap`` command group."""
95
96 parser = subcommands.add_parser(
97 "roadmap",
98 help="Import, export, audit, and version ROADMAP registry data.",
99 )
100 roadmap_commands = parser.add_subparsers(dest="roadmap_command")
101
102 import_parser = roadmap_commands.add_parser("import", help="Import ROADMAP markdown.")
103 import_parser.add_argument("registry", nargs="?")
104 import_parser.add_argument("--dir", dest="roadmap_dir")
105 import_parser.add_argument("--dry-run", action="store_true")
106 import_parser.add_argument("--actor-slot")
107 import_parser.add_argument("--json", action="store_true")
108 import_parser.set_defaults(handler=_cmd_import)
109
110 render_parser = roadmap_commands.add_parser(
111 "render",
112 # OCT-LINT: disable=no_hardcoded_secrets reason="CLI SQL projections and help strings are not secrets"
113 help="Render (or --check) the living ROADMAP GeneratedView set from the "
114 "verified authored projection + source/.",
115 )
116 render_parser.add_argument("registry", nargs="?")
117 render_parser.add_argument("--db", dest="db", help="Authored-projection store (default: workspace).")
118 render_parser.add_argument("--source", dest="source", help="source/ tree (default: workspace).")
119 # OCT-LINT: disable=no_hardcoded_secrets reason="CLI SQL projections and help strings are not secrets"
120 render_parser.add_argument("--out", dest="out", help="Roadmap dir for ROADMAP*.md (default: workspace).")
121 # OCT-LINT: disable=no_hardcoded_secrets reason="CLI SQL projections and help strings are not secrets"
122 render_parser.add_argument("--root", dest="root", help="Root dir for CHANGELOG.md (default: workspace root).")
123 render_parser.add_argument("--published", help="Stamp this 'Updated' date into the header.")
124 render_parser.add_argument("--check", action="store_true", help="Audit on-disk views for drift; do not write.")
125 render_parser.add_argument("--json", action="store_true")
126 render_parser.set_defaults(handler=_cmd_render)
127
128 # Plan 205: deliberately attached HERE, next to `render --check`, because the
129 # two are read together at round close and answer DIFFERENT questions. Drift
130 # compares the rendered views against the source records; reconcile compares
131 # the source records against what actually landed. Conflating them is what
132 # let M8.0 read `todo` for three weeks after sign-off.
133 reconcile_parser = roadmap_commands.add_parser(
134 "reconcile",
135 # OCT-LINT: disable=no_hardcoded_secrets reason="CLI SQL projections and help strings are not secrets"
136 help="Check roadmap CLAIMS against roadmap STATE (NOT the drift check).",
137 )
138 reconcile_parser.add_argument("registry", nargs="?")
139 reconcile_parser.add_argument("--source", dest="source", help="source/ tree (default: workspace).")
140 reconcile_parser.add_argument("--plans", dest="plans", help="Completed-plans dir (default: workspace).")
141 reconcile_parser.add_argument("--repo", dest="repo", help="Repository root (default: workspace).")
142 reconcile_parser.add_argument(
143 "--since", default=_DISCIPLINE_LANDED,
144 help="Only inspect commits newer than this git date. Defaults to the day "
145 "the round-close discipline landed (%s, plan 205); pre-discipline "
146 "changelog records predate the advances/status-flip rule and are "
147 "grandfathered. Pass an earlier date (or an empty value) to audit "
148 "the full history." % _DISCIPLINE_LANDED,
149 )
150 reconcile_parser.add_argument(
151 "--advances-required-from", dest="advances_required_from",
152 default=_ADVANCES_REQUIRED_FROM,
153 # OCT-LINT: disable=no_hardcoded_secrets reason="CLI SQL projections and help strings are not secrets"
154 help="Lowest plan id the `advances:` field is REQUIRED on; older plans "
155 "missing it are advisory (the field landed 2026-08-14 with plan 205, "
156 "so this defaults to %s — plans 014-204 predate the mechanism and are "
157 "grandfathered)." % _ADVANCES_REQUIRED_FROM,
158 )
159 reconcile_parser.add_argument(
160 "--fail-on-violations",
161 action="store_true",
162 help="Exit 1 when violations are present (default: advisory, always exit 0).",
163 )
164 reconcile_parser.add_argument(
165 "--all", dest="show_all", action="store_true",
166 help="List advisory findings too, not just violations.",
167 )
168 reconcile_parser.add_argument(
169 "--limit", type=int, default=25, help="Max findings to list in human output (0 = all).",
170 )
171 reconcile_parser.add_argument(
172 "--write-links", action="store_true",
173 help="Persist the plan->item join into roadmap_plan_links (live store only).",
174 )
175 reconcile_parser.add_argument("--actor-slot")
176 reconcile_parser.add_argument("--json", action="store_true")
177 reconcile_parser.set_defaults(handler=_cmd_reconcile)
178
179 audit_parser = roadmap_commands.add_parser("audit", help="Audit ROADMAP drift.")
180 audit_parser.add_argument("registry", nargs="?")
181 audit_parser.add_argument("--dir", dest="roadmap_dir")
182 audit_parser.add_argument("--version")
183 audit_parser.add_argument("--json", action="store_true")
184 audit_parser.set_defaults(handler=_cmd_audit)
185
186 bump_parser = roadmap_commands.add_parser("bump", help="Create a draft roadmap version.")
187 bump_parser.add_argument("registry", nargs="?")
188 bump_parser.add_argument("--from", dest="from_version")
189 bump_parser.add_argument("--to", dest="to_version", required=True)
190 bump_parser.add_argument("--kind", default="patch")
191 bump_parser.add_argument("--actor-slot")
192 bump_parser.set_defaults(handler=_cmd_bump)
193
194 status_parser = roadmap_commands.add_parser("status", help="Show roadmap store status.")
195 status_parser.add_argument("registry", nargs="?")
196 status_parser.add_argument("--json", action="store_true")
197 status_parser.set_defaults(handler=_cmd_status)
198
199 # Plan 142 P1.3: attach the AuthoredSource `source`/`hash-preimage` commands
200 # (implemented in registry/cli/source.py; one additive wiring line here).
201 add_source_subcommands(roadmap_commands)
202
203
204def _cmd_import(args: argparse.Namespace) -> int:
205 location, backend, registry = _open(args.registry)
206 result = ingest_corpus(
207 registry,
208 args.roadmap_dir or Path(location.workspace_root) / "oc_chat" / "roadmap",
209 actor_slot=args.actor_slot,
210 dry_run=args.dry_run,
211 )
212 _print_result(result.to_dict(), args.json)
213 return 0 if result.ok else 1
214
215
216def _cmd_render(args: argparse.Namespace) -> int:
217 """Render (or --check) the living GeneratedView set (§6).
218
219 Resolves the worktree-local **authored-projection** store + the ``source/``
220 tree — NEVER the shared live registry (the living set renders from the
221 verified projection, not the runtime store).
222 """
223 location = discover_registry_location(registry_path=args.registry)
224 workspace = Path(location.workspace_root)
225 db_path = (
226 Path(args.db) if args.db
227 else workspace / ".option_c" / "orchestration" / "registry_authored.sqlite"
228 )
229 source_dir = Path(args.source) if args.source else workspace / "oc_chat" / "roadmap" / "source"
230 roadmap_dir = Path(args.out) if args.out else workspace / "oc_chat" / "roadmap"
231 root_dir = Path(args.root) if args.root else workspace
232 # N1 (R2): the render CLI has no envelope wrapper and the dispatcher does not
233 # wrap handlers, so a raised SourceError/RegenerateError (e.g. the R2(a)
234 # REGEN_UNVERIFIED_PROJECTION, the R2(b) source-recompute failures) would
235 # traceback through the CLI. Wrap the render body in the §10 envelope +
236 # exit_code_for (copying _cmd_source_format's SHAPE, but mapping the exit via
237 # exit_code_for — SourceScan/Validation -> 1 — not its inline scan->2 choice).
238 try:
239 if args.check:
240 results = render_check(db_path, source_dir, roadmap_dir=roadmap_dir, root_dir=root_dir)
241 drift = [result.to_dict() for result in results if result.drift]
242 payload = {"ok": not drift, "checked": len(results), "drift": drift}
243 _print_result(payload, args.json)
244 return 0 if not drift else 1
245 written = write_living_set(
246 db_path, source_dir, roadmap_dir=roadmap_dir, root_dir=root_dir,
247 published=args.published,
248 )
249 payload = {"ok": True, "files": [str(path) for path in written]}
250 _print_result(payload, args.json)
251 return 0
252 except (SourceError, RegenerateError) as error:
253 _emit_render_envelope(error, args.json)
254 return exit_code_for(error)
255
256
257def _emit_render_envelope(error: Exception, as_json: bool) -> None:
258 """Emit the §10 JSON envelope (or a compact line) for a render failure (N1)."""
259 envelope = build_envelope(error)
260 if as_json:
261 print(json.dumps(envelope, indent=2, sort_keys=True))
262 else:
263 print(f"roadmap render failed [{envelope['code']}]: {envelope['message']}")
264
265
266def _cmd_reconcile(args: argparse.Namespace) -> int:
267 """Compare roadmap CLAIMS against roadmap STATE (Plan 205).
268
269 Advisory by default: it exits 0 with violations present. That is deliberate.
270 The first full-tree run reports the whole pre-existing backlog, and a check
271 that must be green before it can land is a check that gets weakened until it
272 is. ``--fail-on-violations`` is the opt-in for a gate that has been cleaned.
273 """
274 func = "_cmd_reconcile"
275 location = discover_registry_location(registry_path=args.registry)
276 workspace = Path(location.workspace_root)
277 repo_root = Path(args.repo) if args.repo else workspace
278 try:
279 report = reconcile_workspace(
280 repo_root,
281 source_dir=args.source,
282 plans_dir=args.plans,
283 since=args.since,
284 advances_required_from=args.advances_required_from,
285 )
286 except ReconcileConfigError as error:
287 # Exit 2, never 0 or 1: a scope that cannot be read is neither "clean"
288 # nor "violations found", and must not be mistaken for either.
289 print(f"roadmap reconcile: configuration error\n{error}", file=sys.stderr)
290 print(f"roadmap reconcile: configuration error\n{error}")
291 return 2
292 _dbg("OC_CHAT.MAIN", func, f"reconciled {repo_root}", 2)
293
294 payload = report.to_dict()
295 if args.write_links:
296 payload["write_links"] = _write_links(args, report).to_dict()
297 if args.json:
298 print(json.dumps(payload, indent=2, sort_keys=True))
299 else:
300 _print_reconcile(report, payload, show_all=args.show_all, limit=args.limit)
301 return 1 if (args.fail_on_violations and report.would_fail) else 0
302
303
304def _write_links(args: argparse.Namespace, report: object) -> JoinWriteResult:
305 """Persist the plan-link join into the LIVE registry, never the projection."""
306
307 _location, backend, registry = _open(args.registry)
308 actor_slot = args.actor_slot or backend.active_operator_slot()
309 if actor_slot is None:
310 return JoinWriteResult(False, 0, (), (), "no actor slot: pass --actor-slot")
311 return write_plan_links(registry, report.links, actor_slot=actor_slot) # type: ignore[attr-defined]
312
313
315 report: object, payload: dict[str, object], *, show_all: bool, limit: int
316) -> None:
317 """Render the human view, banner first so it can never read as a drift line."""
318
319 print(NOT_DRIFT_BANNER)
320 print()
321 # Said as loudly as a violation, and ABOVE the counts: a blind spot that
322 # renders as a quiet zero is the exact disease this whole plan treats.
323 if not payload["changelog_class_evaluated"]:
324 print(" " + "!" * 68)
325 print(" !! class 1 (changelog-vs-status) was NOT EVALUATED")
326 print(f" !! {payload['changelog_not_evaluated_reason']}")
327 print(" !! its 0 below means 'NOT CHECKED', not 'clean'.")
328 print(" " + "!" * 68)
329 print()
330 print(
331 f"violations: {payload['violations']} advisories: {payload['advisories']} "
332 f"advances-none: {payload['advances_none']} (advisory mode — exit 0)"
333 )
334 print(f" {'kind':<30} {'violation':>9} {'advisory':>9}")
335 for kind, split in sorted(payload["counts_by_severity"].items()): # type: ignore[union-attr]
336 print(f" {kind:<30} {split['violation']:>9} {split['advisory']:>9}")
337 checked = payload["checked"]
338 print(
339 f"checked: {checked['plans']} plans, {checked['changelog_records']} changelog " # type: ignore[index]
340 f"records, {checked['roadmap_records']} roadmap records" # type: ignore[index]
341 )
342 findings = [
343 finding for finding in payload["findings"] # type: ignore[union-attr]
344 if show_all or finding["severity"] == "violation"
345 ]
346 shown = findings if limit <= 0 else findings[:limit]
347 print()
348 for finding in shown:
349 print(f" [{finding['severity']:<9}] {finding['kind']}: {finding['detail']}")
350 if len(shown) < len(findings):
351 print(f" ... {len(findings) - len(shown)} more (raise --limit, or use --json)")
352 if not show_all and payload["advisories"]:
353 print(f" ({payload['advisories']} advisory finding(s) hidden — pass --all)")
354 unscannable = payload["unscannable_ids"]
355 if unscannable:
356 print(
357 f"\nnot scanned in prose (degenerate ids collide with slot handles): "
358 f"{', '.join(unscannable)}" # type: ignore[arg-type]
359 )
360
361
362def _cmd_audit(args: argparse.Namespace) -> int:
363 location, backend, registry = _open(args.registry)
364 result = audit_corpus(
365 registry,
366 args.roadmap_dir or Path(location.workspace_root) / "oc_chat" / "roadmap",
367 version_id=args.version,
368 )
369 _print_result(result.to_dict(), args.json)
370 return 0 if result.ok else 1
371
372
373def _cmd_bump(args: argparse.Namespace) -> int:
374 _location, backend, registry = _open(args.registry)
375 actor_slot = args.actor_slot or backend.active_operator_slot()
376 if actor_slot is None:
377 print("no active operator slot found", file=sys.stderr)
378 return 1
379 roadmap_version_bump(
380 registry,
381 from_version_id=args.from_version,
382 to_version_id=args.to_version,
383 bump_kind=args.kind,
384 actor_slot=actor_slot,
385 actor_type="human_operator",
386 command="roadmap bump",
387 system_actor=True,
388 )
389 print(f"created roadmap draft version {args.to_version}")
390 return 0
391
392
393def _cmd_status(args: argparse.Namespace) -> int:
394 _location, backend, _registry = _open(args.registry)
395 with backend.connect() as conn:
396 payload = {
397 "ok": True,
398 "user_version": conn.execute("PRAGMA user_version").fetchone()[0],
399 # OCT-LINT: disable=no_hardcoded_secrets reason="CLI SQL projections and help strings are not secrets"
400 "versions": conn.execute("SELECT COUNT(*) FROM roadmap_versions").fetchone()[0],
401 # OCT-LINT: disable=no_hardcoded_secrets reason="CLI SQL projections and help strings are not secrets"
402 "items": conn.execute("SELECT COUNT(*) FROM roadmap_items_v3").fetchone()[0],
403 # OCT-LINT: disable=no_hardcoded_secrets reason="CLI SQL projections and help strings are not secrets"
404 "events": conn.execute("SELECT COUNT(*) FROM roadmap_item_events").fetchone()[0],
405 }
406 _print_result(payload, args.json)
407 return 0
408
409
410def _open(registry_path: str | None) -> tuple[object, SQLiteBackend, Registry]:
411 location = discover_registry_location(registry_path=registry_path)
412 backend = SQLiteBackend(location.registry_path)
413 return location, backend, Registry(backend)
414
415
416def _print_result(payload: dict[str, object], as_json: bool) -> None:
417 if as_json:
418 print(json.dumps(payload, indent=2, sort_keys=True))
419 return
420 status = "ok" if payload.get("ok") else "failed"
421 print(f"roadmap {status}")
422 for key, value in payload.items():
423 if key == "ok":
424 continue
425 print(f" {key}: {value}")
None _print_reconcile(object report, dict[str, object] payload, *, bool show_all, int limit)
Definition roadmap.py:316
int _cmd_reconcile(argparse.Namespace args)
Definition roadmap.py:266
int _cmd_status(argparse.Namespace args)
Definition roadmap.py:393
int _cmd_import(argparse.Namespace args)
Definition roadmap.py:204
None _emit_render_envelope(Exception error, bool as_json)
Definition roadmap.py:257
int _cmd_audit(argparse.Namespace args)
Definition roadmap.py:362
None _print_result(dict[str, object] payload, bool as_json)
Definition roadmap.py:416
int _cmd_render(argparse.Namespace args)
Definition roadmap.py:216
None add_roadmap_subcommands(argparse._SubParsersAction subcommands)
Definition roadmap.py:93
int _cmd_bump(argparse.Namespace args)
Definition roadmap.py:373
JoinWriteResult _write_links(argparse.Namespace args, object report)
Definition roadmap.py:304
tuple[object, SQLiteBackend, Registry] _open(str|None registry_path)
Definition roadmap.py:410