Coverage for cli / commands / mission_cmd.py: 100.00%
611 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-09-14 22:07 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-09-14 22:07 +0000
1"""Mission goal-directed iteration loop CLI commands.
3The whole subcommand group is gated by ``GCO_ENABLE_MISSION``: when
4the env var is unset, the group prints a one-line hint and exits with
5code 2 before dispatching to any subcommand. With the flag set, the
6nine subcommands talk directly to the persistence backend and the
7:class:`mission.engine.MissionEngine` — no MCP round-trip is involved
8so the CLI works without the MCP server running.
10Subcommands:
12* ``start`` — validate inputs, resolve sampling state, persist a new
13 ``SessionState``. With ``--run``, iterate to completion synchronously.
14* ``status`` — read the full session JSON.
15* ``iterate`` — drive one or more iterations of an existing session.
16* ``checkpoint`` — re-run the verdict cascade on the latest iteration.
17* ``complete`` — force a session into ``completed``.
18* ``abort`` — pause or terminate a session.
19* ``resume`` — transition ``paused`` to ``running``.
20* ``history`` — return the iteration history (full or summary).
21* ``list`` — list sessions across the configured backend.
23Output formats: every subcommand defaults to ``--output json``; pass
24``--output table`` for a human-readable summary.
25"""
27from __future__ import annotations
29import asyncio
30import json
31import os
32import secrets
33import sys
34from collections.abc import Mapping
35from datetime import UTC, datetime
36from pathlib import Path
37from typing import TYPE_CHECKING, Any, cast
39import click
41# The Mission package lives under ``gco_mcp/mission/`` and is imported as
42# ``mission.*``. Match the path-injection pattern used throughout the
43# MCP module surface and the ``test_mission_*`` test files so the
44# imports below resolve regardless of how this module is loaded.
45sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent / "gco_mcp"))
47from gco.bedrock import BedrockFTUFormNotAcceptedError # noqa: E402
49if TYPE_CHECKING: # pragma: no cover - import only for type checkers
50 from mission.types import SessionState
53_FEATURE_FLAG_HINT = (
54 "Mission tools are gated. Set GCO_ENABLE_MISSION=true (or GCO_ENABLE_ALL_TOOLS=true) to enable."
55)
58def _flag_enabled() -> bool:
59 """Return True iff ``GCO_ENABLE_MISSION`` (or umbrella) is truthy."""
60 truthy = {"true", "1", "yes", "on"}
61 return (
62 os.environ.get("GCO_ENABLE_MISSION", "").strip().lower() in truthy
63 or os.environ.get("GCO_ENABLE_ALL_TOOLS", "").strip().lower() in truthy
64 )
67def _check_feature_flag() -> None:
68 """Print the hint and exit with code 2 when the gating flag is unset."""
69 if not _flag_enabled():
70 click.echo(_FEATURE_FLAG_HINT, err=True)
71 raise SystemExit(2)
74# ---------------------------------------------------------------------------
75# Output helpers
76# ---------------------------------------------------------------------------
79def _strip_private_criteria(session: Mapping[str, Any]) -> dict[str, Any]:
80 """Return a JSON-safe copy of ``session`` with private criterion keys dropped.
82 Thin alias over :func:`mission.validation.strip_private_fields` —
83 the canonical implementation lives next to ``validate_criteria``
84 (which creates the ``_parsed_ast`` keys). Kept under the older
85 ``_strip_private_criteria`` name so the call sites in this file
86 don't churn while the underlying logic is consolidated.
87 """
88 from mission.validation import strip_private_fields # noqa: PLC0415
90 cleaned: dict[str, Any] = strip_private_fields(session)
91 return cleaned
94def _strip_iteration(iteration: Any) -> Any:
95 """Strip private keys from an iteration's ``criteria_evaluation`` shape.
97 Thin alias over the iteration variant of the canonical helper.
98 Returns non-dict input verbatim so a corrupt history entry stays
99 observable to the caller.
100 """
101 if not isinstance(iteration, Mapping):
102 return iteration
103 from mission.validation import strip_private_fields_iterations # noqa: PLC0415
105 return strip_private_fields_iterations([iteration])[0]
108def _emit_json(payload: Any, *, err: bool = False) -> None:
109 """Emit ``payload`` as a single JSON line.
111 ``default=str`` keeps any straggling datetime / Path objects from
112 raising — the engine's persisted shapes are already pure JSON, but
113 a CLI command may surface a partially-built dict (e.g., the start
114 summary before save) and we want every output path to succeed.
115 """
116 from ..output import emit_structured_document
118 emit_structured_document(
119 payload,
120 output_format="json",
121 rendered=json.dumps(payload, default=str),
122 err=err,
123 )
126def _emit_json_text(text: str) -> None:
127 """Emit pre-rendered JSON while registering its native document shape."""
128 from ..output import emit_structured_document
130 try:
131 document = json.loads(text)
132 except json.JSONDecodeError:
133 click.echo(text)
134 return
135 emit_structured_document(document, output_format="json", rendered=text)
138def _emit_error(code: str, details: dict[str, Any] | None = None) -> None:
139 """Emit a structured error envelope to stderr."""
140 payload: dict[str, Any] = {"code": code}
141 if details is not None:
142 payload["details"] = details
143 _emit_json(payload, err=True)
146# ---------------------------------------------------------------------------
147# Stub dispatcher
148# ---------------------------------------------------------------------------
151def _make_stub_dispatcher() -> Any:
152 """Return a tool dispatcher that returns canned responses.
154 Thin wrapper around :func:`mcp.mission._engine_factory.make_stub_dispatcher`
155 kept for backward compat with the small set of tests that import
156 this name directly. Production paths now go through
157 :func:`_build_engine` which decides between the live FastMCP
158 dispatcher and this stub based on ``--dry-run`` opt-in.
159 """
160 from mission._engine_factory import make_stub_dispatcher # noqa: PLC0415
162 return make_stub_dispatcher()
165# ---------------------------------------------------------------------------
166# Click group
167# ---------------------------------------------------------------------------
170@click.group("mission")
171def mission_cmd() -> None:
172 """Mission goal-directed iteration loop commands.
174 Subcommands manage Mission sessions: ``start``, ``status``,
175 ``iterate``, ``checkpoint``, ``complete``, ``abort``, ``resume``,
176 ``history``, ``list``, plus the ``memory`` group
177 (``search`` / ``list`` / ``backfill``) over the institutional
178 mission-memory index.
180 Gated by the ``GCO_ENABLE_MISSION`` environment variable. With
181 the flag unset, every subcommand prints a one-line hint to stderr
182 and exits with code 2.
183 """
184 _check_feature_flag()
187# ---------------------------------------------------------------------------
188# start
189# ---------------------------------------------------------------------------
192@mission_cmd.command("start")
193@click.option("--directive", required=True, help="Natural-language goal description.")
194@click.option(
195 "--criteria-file",
196 type=click.Path(exists=True, dir_okay=False),
197 default=None,
198 help="JSON file containing the criteria list. Required unless --with-defaults is set.",
199)
200@click.option(
201 "--max-iterations",
202 type=int,
203 required=True,
204 help="Hard cap on the iteration count. Pass -1 to opt out (uncapped).",
205)
206@click.option(
207 "--max-wall-clock",
208 type=int,
209 required=True,
210 help="Hard cap on wall-clock seconds. Pass -1 to opt out (uncapped).",
211)
212@click.option(
213 "--tool-allowlist",
214 multiple=True,
215 help="Tool name to allowlist; pass multiple times. Optional with --allow-all-tools.",
216)
217@click.option(
218 "--allow-all-tools",
219 is_flag=True,
220 help=(
221 "Resolve the session's tool allowlist to every registered MCP tool "
222 "(minus the mission_* control tools). Makes --tool-allowlist optional; "
223 "mutually exclusive with it."
224 ),
225)
226@click.option(
227 "--cadence",
228 type=click.Choice(["every_iteration", "every_n_iterations", "every_t_seconds", "on_event"]),
229 default="every_iteration",
230 show_default=True,
231 help="Checkpoint cadence kind.",
232)
233@click.option("--cadence-n", type=int, default=None, help="Cadence n parameter.")
234@click.option(
235 "--cadence-t",
236 type=int,
237 default=None,
238 help="Cadence t parameter (seconds).",
239)
240@click.option(
241 "--cadence-event",
242 default=None,
243 help="Cadence event_name parameter.",
244)
245@click.option(
246 "--stagnation-threshold",
247 type=int,
248 default=3,
249 show_default=True,
250 help="Iterations of no progress before terminate.",
251)
252@click.option(
253 "--use-sampling/--no-sampling",
254 "use_sampling",
255 default=None,
256 help="Enable/disable LLM sampling (default: auto-detect).",
257)
258@click.option(
259 "--bedrock-model-id",
260 default=None,
261 help="Override the Bedrock model id used by the CLI sampling backend.",
262)
263@click.option(
264 "--allow-scripted-strategies",
265 is_flag=True,
266 help="Allow scripted strategies to run via the Mission sandbox.",
267)
268@click.option(
269 "--with-defaults",
270 is_flag=True,
271 help="Use a basic placeholder predicate criterion when no --criteria-file is provided.",
272)
273@click.option(
274 "--run",
275 "run_mode",
276 is_flag=True,
277 help="Iterate to completion synchronously after creating the session.",
278)
279@click.option(
280 "--dry-run",
281 "dry_run",
282 is_flag=True,
283 help=(
284 "Use a stub tool dispatcher and disable Strategy_Revision sampling "
285 "during iteration. Useful for smoke-testing the loop bookkeeping "
286 "without spending Bedrock or AWS credits. Only meaningful with --run."
287 ),
288)
289@click.option(
290 "--output",
291 type=click.Choice(["json", "table"]),
292 default="json",
293 show_default=True,
294 help="Output format.",
295)
296def mission_start(
297 directive: str,
298 criteria_file: str | None,
299 max_iterations: int,
300 max_wall_clock: int,
301 tool_allowlist: tuple[str, ...],
302 allow_all_tools: bool,
303 cadence: str,
304 cadence_n: int | None,
305 cadence_t: int | None,
306 cadence_event: str | None,
307 stagnation_threshold: int,
308 use_sampling: bool | None,
309 bedrock_model_id: str | None,
310 allow_scripted_strategies: bool,
311 with_defaults: bool,
312 run_mode: bool,
313 dry_run: bool,
314 output: str,
315) -> None:
316 """Start a new Mission session.
318 Validates inputs through the shared validators in
319 ``mission.validation``, resolves the sampling state via
320 ``mission.sampling.resolve_sampling_state``, and persists the
321 session through the configured backend (``GCO_MISSION_STATE_BACKEND``,
322 defaults to filesystem under ``~/.gco/missions``).
324 With ``--run``, iterates to completion synchronously: each verdict
325 is printed as one JSON line to stderr; the final stdout is the
326 Final_Report JSON.
327 """
328 from mission import ( # noqa: PLC0415 — lazy: avoids cost when help-only
329 sampling as mission_sampling,
330 )
331 from mission import (
332 state as mission_state,
333 )
334 from mission import (
335 validation as mission_validation,
336 )
337 from mission.types import SCHEMA_VERSION
338 from mission.validation import MissionValidationError
340 # Build the criteria list from the file or the placeholder default.
341 criteria: list[dict[str, Any]]
342 if criteria_file:
343 try:
344 with open(criteria_file, encoding="utf-8") as fp:
345 criteria = json.load(fp)
346 except (OSError, ValueError) as exc:
347 _emit_error(
348 "validation_error",
349 {"field": "criteria-file", "reason": str(exc)},
350 )
351 sys.exit(1)
352 elif with_defaults:
353 criteria = [
354 {
355 "criterion_id": "default",
356 "kind": "predicate",
357 "required": True,
358 "expression": "True",
359 }
360 ]
361 else:
362 _emit_error(
363 "validation_error",
364 {
365 "field": "criteria",
366 "reason": "either --criteria-file or --with-defaults is required",
367 },
368 )
369 sys.exit(1)
371 # Build the budget dict.
372 budget: dict[str, Any] = {
373 "max_iterations": max_iterations,
374 "max_wall_clock_seconds": max_wall_clock,
375 }
377 # Build the cadence dict.
378 cadence_dict: dict[str, Any] = {"kind": cadence}
379 if cadence_n is not None:
380 cadence_dict["n"] = cadence_n
381 if cadence_t is not None:
382 cadence_dict["t"] = cadence_t
383 if cadence_event is not None:
384 cadence_dict["event_name"] = cadence_event
386 # Resolve the effective allowlist before any persistence. The explicit
387 # path keeps the thin CLI behaviour (no live-registry per-name check); the
388 # all-tools path resolves from the on-demand registry. A rejection here
389 # emits a structured envelope and exits before any session is built.
390 allowlist_resolved = _resolve_cli_allowlist(
391 allow_all_tools=allow_all_tools, tool_allowlist=tool_allowlist
392 )
394 # Validate inputs. The CLI has no live FastMCP tool registry on the
395 # explicit path, so the tool-allowlist validator is skipped and the
396 # budget validator gets an empty tag map — meaning a CLI-started session
397 # with a cost-incurring tool will only be caught at iterate time when the
398 # engine routes through the real tool dispatcher. The MCP tool surface
399 # performs the full validation; the CLI is intentionally a thin
400 # smoke-test path.
401 try:
402 directive_clean = mission_validation.validate_directive(directive)
403 criteria_clean = mission_validation.validate_criteria(criteria)
404 budget_clean = mission_validation.validate_budget(budget, allowlist_resolved, {})
405 cadence_clean = mission_validation.validate_cadence(cadence_dict)
406 except MissionValidationError as exc:
407 _emit_error(exc.code, exc.details)
408 sys.exit(1)
410 if not isinstance(stagnation_threshold, int) or stagnation_threshold <= 0:
411 _emit_error(
412 "validation_error",
413 {"field": "stagnation-threshold", "reason": "must_be_positive_int"},
414 )
415 sys.exit(1)
417 # Resolve sampling state. The helper probes local AWS credentials and
418 # returns ``(True, "bedrock")`` when they resolve.
419 use_sampling_resolved, backend_resolved = mission_sampling.resolve_sampling_state(use_sampling)
421 session_id = f"mission-{secrets.token_hex(8)}"
422 now_iso = datetime.now(UTC).isoformat()
423 session: dict[str, Any] = {
424 "version": SCHEMA_VERSION,
425 "session_id": session_id,
426 "directive_text": directive_clean,
427 "criteria": criteria_clean,
428 "budget": budget_clean,
429 "tool_allowlist": allowlist_resolved,
430 "checkpoint_cadence": cadence_clean,
431 "stagnation_threshold": stagnation_threshold,
432 "use_sampling": use_sampling_resolved,
433 "sampling_backend_resolved": backend_resolved,
434 "allow_scripted_strategies": bool(allow_scripted_strategies),
435 "status": "pending",
436 "created_at": now_iso,
437 "iterations": [],
438 "no_progress_counter": 0,
439 }
440 if bedrock_model_id:
441 session["bedrock_model_id"] = bedrock_model_id
443 backend = mission_state.get_backend()
445 # ``save_session`` will not accept the cached ``_parsed_ast`` AST on
446 # predicate criteria when the backend is the filesystem JSON writer.
447 # Strip them just before persistence; the validators left them on
448 # the in-memory copy so the engine can use them at iterate time —
449 # we'll re-validate when iterate next runs against the loaded
450 # session.
451 backend.save_session(cast("SessionState", _strip_private_criteria(session)))
453 summary = {
454 "session_id": session_id,
455 "status": "pending",
456 "use_sampling": use_sampling_resolved,
457 "sampling_backend_resolved": backend_resolved,
458 }
460 if not run_mode:
461 if output == "table":
462 click.echo(f"Session ID: {session_id}")
463 click.echo("Status: pending")
464 click.echo(
465 f"Sampling: {'on' if use_sampling_resolved else 'off'} ({backend_resolved})"
466 )
467 else:
468 _emit_json(summary)
469 return
471 # --run mode: iterate to completion.
472 _run_to_completion(session_id, dry_run=dry_run)
475def _run_to_completion(session_id: str, *, dry_run: bool = False) -> None:
476 """Drive ``session_id`` through iterations until terminal verdict.
478 When ``dry_run`` is False (the default), wires the live FastMCP
479 dispatcher and the Strategy_Revision sampling callable through
480 :func:`mcp.mission._engine_factory.build_mission_engine` so the
481 loop can actually iterate against real tools and let the model
482 revise the strategy between iterations. When ``dry_run`` is True,
483 falls back to the canned-stub dispatcher and disables sampling so
484 the CLI can smoke-test the loop bookkeeping without spending
485 Bedrock or AWS credits.
487 Writes one JSON line per iteration's verdict to stderr; the final
488 stdout is the Final_Report JSON when present, falling back to the
489 persisted session JSON otherwise.
490 """
491 from mission import state as mission_state # noqa: PLC0415
492 from mission._engine_factory import build_mission_engine # noqa: PLC0415
493 from mission.engine import MissionEngineError # noqa: PLC0415
494 from mission.state import FilesystemBackend # noqa: PLC0415
496 backend = mission_state.get_backend()
497 session_for_runner = backend.load_session(session_id)
498 if session_for_runner is None:
499 _emit_error("session_not_found", {"session_id": session_id})
500 sys.exit(1)
502 # Populate the FastMCP tool registry so the live dispatcher can
503 # find the operator-allowlisted tools. Safe to call repeatedly —
504 # ``register_all_tools`` is idempotent (FastMCP rejects duplicate
505 # registrations after the first call). Skipped on the dry-run path
506 # because the stub dispatcher never consults the registry.
507 if not dry_run:
508 _ensure_tool_registry()
510 async def _drive() -> None:
511 engine = await build_mission_engine(
512 session_for_runner, ctx=None, use_stub_dispatcher=dry_run
513 )
514 while True:
515 try:
516 record = await engine.run_iteration(session_id, ctx=None)
517 except MissionEngineError as exc:
518 _emit_error(exc.code, {"session_id": session_id})
519 sys.exit(1)
520 _emit_json(
521 {
522 "iteration_index": record["iteration_index"],
523 "verdict": record["verdict"],
524 "verdict_reason": record["verdict_reason"],
525 },
526 err=True,
527 )
528 if record["verdict"] in ("complete", "terminate"):
529 break
531 asyncio.run(_drive())
533 # Emit the final report when the filesystem backend wrote one;
534 # fall back to the persisted session for other backends.
535 session = backend.load_session(session_id)
536 if isinstance(backend, FilesystemBackend):
537 report_path = backend.root / f"{session_id}.report.json"
538 if report_path.exists():
539 _emit_json_text(report_path.read_text(encoding="utf-8"))
540 return
541 if session is not None:
542 _emit_json(_strip_private_criteria(session))
543 else:
544 _emit_error("session_disappeared", {"session_id": session_id})
545 sys.exit(1)
548def _ensure_tool_registry() -> None:
549 """Register every MCP tool against the shared FastMCP server, once.
551 The CLI doesn't normally boot the MCP server, so its FastMCP
552 instance starts empty. The live tool dispatcher in the engine
553 factory looks up tools on that instance, so we eagerly register
554 every tool group up-front when the live path is selected. The
555 underlying ``register_all_tools`` is import-time side-effects on
556 module load; calling it twice is harmless because the per-module
557 decorators only fire on the first import.
558 """
559 sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent / "gco_mcp"))
560 from tools import register_all_tools # noqa: PLC0415
562 register_all_tools()
565def _resolve_registered_tools_for_cli() -> tuple[dict[str, Any], set[str]]:
566 """Register every MCP tool on demand and snapshot the live registry.
568 Returns a ``(name -> Tool, control-tool names)`` pair. The control set is
569 derived from the ``"mission"`` tag, so it auto-adapts if a tenth
570 session-management tool is ever added. Calls the idempotent
571 :func:`_ensure_tool_registry` first, then lists tools through
572 ``mcp._list_tools()`` — the same low-level path the engine factory uses.
573 Returns ``({}, set())`` only when the registry genuinely holds no tools,
574 which the resolver then rejects as ``allow_all_tools_empty_registry``.
575 """
576 _ensure_tool_registry()
577 from server import mcp # noqa: PLC0415 — lazy
579 async def _list() -> list[Any]:
580 return list(await mcp._list_tools())
582 tools = asyncio.run(_list())
583 registered = {t.name: t for t in tools}
584 control = {t.name for t in tools if "mission" in (getattr(t, "tags", None) or set())}
585 return registered, control
588def _resolve_cli_allowlist(*, allow_all_tools: bool, tool_allowlist: tuple[str, ...]) -> list[str]:
589 """Resolve a subcommand's effective tool allowlist or exit with code 1.
591 The all-tools branch populates the registry on demand and resolves the
592 effective list from it. The explicit branch preserves the thin CLI path
593 (no per-name registry check) but enforces at-least-one, emitting the
594 existing ``empty`` rejection when no name is supplied. On any
595 :class:`MissionValidationError` the structured envelope is emitted and the
596 process exits 1 — before the caller builds or persists a session.
597 """
598 from mission import validation as mission_validation # noqa: PLC0415
599 from mission.validation import MissionValidationError # noqa: PLC0415
601 if allow_all_tools:
602 registered_tools, control_tools = _resolve_registered_tools_for_cli()
603 try:
604 resolved: list[str] = mission_validation.resolve_effective_allowlist(
605 allow_all_tools=True,
606 explicit_allowlist=list(tool_allowlist),
607 registered_tools=registered_tools,
608 control_tools=control_tools,
609 )
610 except MissionValidationError as exc:
611 _emit_error(exc.code, exc.details)
612 sys.exit(1)
613 return resolved
614 if not tool_allowlist:
615 _emit_error("validation_error", {"field": "tool_allowlist", "reason": "empty"})
616 sys.exit(1)
617 return list(tool_allowlist)
620# ---------------------------------------------------------------------------
621# status
622# ---------------------------------------------------------------------------
625@mission_cmd.command("status")
626@click.argument("session_id")
627@click.option(
628 "--output",
629 type=click.Choice(["json", "table"]),
630 default="json",
631 show_default=True,
632)
633def mission_status_cmd(session_id: str, output: str) -> None:
634 """Get the full state of a Mission session."""
635 from mission.state import get_backend # noqa: PLC0415
637 backend = get_backend()
638 session = backend.load_session(session_id)
639 if session is None:
640 _emit_error("session_not_found", {"session_id": session_id})
641 sys.exit(1)
642 cleaned = _strip_private_criteria(session)
643 if output == "table":
644 click.echo(f"Session ID: {cleaned.get('session_id', '')}")
645 click.echo(f"Status: {cleaned.get('status', '')}")
646 click.echo(f"Directive: {cleaned.get('directive_text', '')}")
647 click.echo(f"Iterations: {len(cleaned.get('iterations', []) or [])}")
648 allowlist = cleaned.get("tool_allowlist", []) or []
649 click.echo(f"Allowlist: {', '.join(allowlist)}")
650 click.echo(
651 f"Sampling: {'on' if cleaned.get('use_sampling') else 'off'} "
652 f"({cleaned.get('sampling_backend_resolved', 'none')})"
653 )
654 else:
655 _emit_json(cleaned)
658# ---------------------------------------------------------------------------
659# iterate
660# ---------------------------------------------------------------------------
663@mission_cmd.command("iterate")
664@click.argument("session_id")
665@click.option(
666 "--max-iterations",
667 type=int,
668 default=1,
669 show_default=True,
670 help="How many iterations to run in this call.",
671)
672@click.option(
673 "--dry-run",
674 "dry_run",
675 is_flag=True,
676 help=(
677 "Use a stub tool dispatcher and disable Strategy_Revision sampling. "
678 "Useful for smoke-testing the loop without spending Bedrock or AWS credits."
679 ),
680)
681@click.option(
682 "--output",
683 type=click.Choice(["json", "table"]),
684 default="json",
685 show_default=True,
686)
687def mission_iterate_cmd(session_id: str, max_iterations: int, dry_run: bool, output: str) -> None:
688 """Run one or more iterations on a Mission session.
690 Stops early on a terminal verdict. By default the engine is wired
691 with the live FastMCP tool dispatcher and the Strategy_Revision
692 sampling callable so the loop iterates against real tool results
693 and lets the model revise the strategy between iterations.
695 Pass ``--dry-run`` to substitute the canned-stub dispatcher and
696 disable sampling — useful for smoke-testing the bookkeeping
697 without spending Bedrock or AWS credits.
698 """
699 from mission._engine_factory import build_mission_engine # noqa: PLC0415
700 from mission.engine import MissionEngineError # noqa: PLC0415
701 from mission.state import get_backend # noqa: PLC0415
703 if max_iterations <= 0:
704 # This is the per-call iteration count (how many iterations to
705 # run THIS call), NOT the session-wide ``budget.max_iterations``
706 # cap. The budget cap accepts ``-1`` as the "uncapped" sentinel;
707 # this per-call count must always be a positive int because a
708 # zero or negative value here would be a no-op invocation.
709 _emit_error(
710 "validation_error",
711 {"field": "max-iterations", "reason": "must_be_positive_int"},
712 )
713 sys.exit(1)
715 backend = get_backend()
716 session_for_runner = backend.load_session(session_id)
717 if session_for_runner is None:
718 _emit_error("session_not_found", {"session_id": session_id})
719 sys.exit(1)
721 if not dry_run:
722 _ensure_tool_registry()
724 async def _drive() -> dict[str, Any]:
725 engine = await build_mission_engine(
726 session_for_runner, ctx=None, use_stub_dispatcher=dry_run
727 )
728 records: list[dict[str, Any]] = []
729 for _ in range(max_iterations):
730 try:
731 record = await engine.run_iteration(session_id, ctx=None)
732 except MissionEngineError as exc:
733 return {
734 "session_id": session_id,
735 "error": {"code": exc.code},
736 "iterations": records,
737 }
738 records.append(
739 {
740 "iteration_index": record["iteration_index"],
741 "verdict": record["verdict"],
742 "verdict_reason": record["verdict_reason"],
743 }
744 )
745 if record["verdict"] in ("complete", "terminate"):
746 break
747 return {"session_id": session_id, "iterations": records}
749 result = asyncio.run(_drive())
751 if "error" in result:
752 _emit_error(result["error"]["code"], {"session_id": session_id})
753 sys.exit(1)
755 if output == "table":
756 for it in result.get("iterations", []):
757 click.echo(
758 f" Iteration {it['iteration_index']}: {it['verdict']} ({it['verdict_reason']})"
759 )
760 else:
761 _emit_json(result)
764# ---------------------------------------------------------------------------
765# checkpoint
766# ---------------------------------------------------------------------------
769@mission_cmd.command("checkpoint")
770@click.argument("session_id")
771@click.option(
772 "--output",
773 type=click.Choice(["json", "table"]),
774 default="json",
775 show_default=True,
776)
777def mission_checkpoint_cmd(session_id: str, output: str) -> None:
778 """Re-run the verdict cascade on the latest iteration of a session."""
779 from mission.decide import decide_verdict # noqa: PLC0415
780 from mission.state import get_backend # noqa: PLC0415
782 backend = get_backend()
783 session = backend.load_session(session_id)
784 if session is None:
785 _emit_error("session_not_found", {"session_id": session_id})
786 sys.exit(1)
787 iterations = session.get("iterations") or []
788 if not iterations:
789 _emit_error("no_iterations", {"session_id": session_id})
790 sys.exit(1)
791 latest = iterations[-1]
792 verdict, reason = decide_verdict(session, latest, datetime.now(UTC))
793 payload = {
794 "session_id": session_id,
795 "iteration_index": latest.get("iteration_index"),
796 "verdict": verdict,
797 "verdict_reason": reason,
798 }
799 if output == "table":
800 click.echo(f"Iteration {payload['iteration_index']}: {verdict} ({reason})")
801 else:
802 _emit_json(payload)
805# ---------------------------------------------------------------------------
806# complete
807# ---------------------------------------------------------------------------
810@mission_cmd.command("complete")
811@click.argument("session_id")
812@click.option(
813 "--output",
814 type=click.Choice(["json", "table"]),
815 default="json",
816 show_default=True,
817)
818def mission_complete_cmd(session_id: str, output: str) -> None:
819 """Force a Mission session into ``completed`` status."""
820 from mission.state import get_backend # noqa: PLC0415
821 from mission.types import TERMINAL_STATES # noqa: PLC0415
823 backend = get_backend()
824 session = backend.load_session(session_id)
825 if session is None:
826 _emit_error("session_not_found", {"session_id": session_id})
827 sys.exit(1)
828 if session["status"] in TERMINAL_STATES:
829 _emit_error(
830 "session_terminal",
831 {"session_id": session_id, "status": session["status"]},
832 )
833 sys.exit(1)
834 now_iso = datetime.now(UTC).isoformat()
835 session["status"] = "completed"
836 session["final_verdict"] = "complete"
837 session["ended_at"] = now_iso
838 backend.save_session(cast("SessionState", _strip_private_criteria(session)))
839 payload = {
840 "session_id": session_id,
841 "status": "completed",
842 "final_verdict": "complete",
843 }
844 if output == "table":
845 click.echo(f"Session {session_id}: completed (forced)")
846 else:
847 _emit_json(payload)
850# ---------------------------------------------------------------------------
851# abort
852# ---------------------------------------------------------------------------
855@mission_cmd.command("abort")
856@click.argument("session_id")
857@click.option("--pause", is_flag=True, help="Pause the session instead of terminating.")
858@click.option(
859 "--output",
860 type=click.Choice(["json", "table"]),
861 default="json",
862 show_default=True,
863)
864def mission_abort_cmd(session_id: str, pause: bool, output: str) -> None:
865 """Pause or terminate a Mission session.
867 With ``--pause``, transitions the session to ``paused`` (resumable).
868 Without ``--pause``, transitions to ``terminated`` and stamps the
869 final verdict.
870 """
871 from mission.state import get_backend # noqa: PLC0415
872 from mission.types import TERMINAL_STATES # noqa: PLC0415
874 backend = get_backend()
875 session = backend.load_session(session_id)
876 if session is None:
877 _emit_error("session_not_found", {"session_id": session_id})
878 sys.exit(1)
879 if session["status"] in TERMINAL_STATES:
880 _emit_error(
881 "session_terminal",
882 {"session_id": session_id, "status": session["status"]},
883 )
884 sys.exit(1)
885 if pause:
886 session["status"] = "paused"
887 else:
888 now_iso = datetime.now(UTC).isoformat()
889 session["status"] = "terminated"
890 session["final_verdict"] = "terminate"
891 session["ended_at"] = now_iso
892 backend.save_session(cast("SessionState", _strip_private_criteria(session)))
893 payload = {"session_id": session_id, "status": session["status"]}
894 if output == "table":
895 click.echo(f"Session {session_id}: {session['status']}")
896 else:
897 _emit_json(payload)
900# ---------------------------------------------------------------------------
901# resume
902# ---------------------------------------------------------------------------
905@mission_cmd.command("resume")
906@click.argument("session_id")
907@click.option(
908 "--output",
909 type=click.Choice(["json", "table"]),
910 default="json",
911 show_default=True,
912)
913def mission_resume_cmd(session_id: str, output: str) -> None:
914 """Resume a paused Mission session."""
915 from mission.state import get_backend # noqa: PLC0415
917 backend = get_backend()
918 session = backend.load_session(session_id)
919 if session is None:
920 _emit_error("session_not_found", {"session_id": session_id})
921 sys.exit(1)
922 if session["status"] != "paused":
923 _emit_error(
924 "invalid_state",
925 {"session_id": session_id, "status": session["status"]},
926 )
927 sys.exit(1)
928 session["status"] = "running"
929 backend.save_session(cast("SessionState", _strip_private_criteria(session)))
930 payload = {"session_id": session_id, "status": "running"}
931 if output == "table":
932 click.echo(f"Session {session_id}: running")
933 else:
934 _emit_json(payload)
937# ---------------------------------------------------------------------------
938# history
939# ---------------------------------------------------------------------------
942@mission_cmd.command("history")
943@click.argument("session_id")
944@click.option(
945 "--format",
946 "fmt",
947 type=click.Choice(["full", "summary"]),
948 default="summary",
949 show_default=True,
950 help="Iteration history detail level.",
951)
952@click.option(
953 "--include-observations",
954 "include_obs",
955 is_flag=True,
956 help=(
957 "Include the observation and strategy dicts in each iteration's "
958 "output. Only meaningful with --format full. Useful for debugging "
959 "what each tool returned and what strategy was proposed."
960 ),
961)
962@click.option(
963 "--output",
964 type=click.Choice(["json", "table"]),
965 default="json",
966 show_default=True,
967)
968def mission_history_cmd(session_id: str, fmt: str, include_obs: bool, output: str) -> None:
969 """Get the iteration history of a Mission session."""
970 from mission.state import get_backend # noqa: PLC0415
972 backend = get_backend()
973 session = backend.load_session(session_id)
974 if session is None:
975 _emit_error("session_not_found", {"session_id": session_id})
976 sys.exit(1)
977 iterations = session.get("iterations") or []
979 if fmt == "full":
980 cleaned = [_strip_iteration(it) for it in iterations]
981 if not include_obs:
982 # Strip observation and strategy from the output to keep it
983 # concise. Operators who need the full shape pass
984 # --include-observations.
985 for it in cleaned:
986 if isinstance(it, dict):
987 it.pop("observation", None)
988 it.pop("strategy", None)
989 if output == "table":
990 for it in cleaned:
991 if not isinstance(it, dict):
992 continue
993 idx = it.get("iteration_index", "?")
994 verdict = it.get("verdict", "?")
995 reason = it.get("verdict_reason", "?")
996 click.echo(f" Iteration {idx}: {verdict} ({reason})")
997 if include_obs:
998 obs = it.get("observation", {})
999 results = obs.get("tool_results", [])
1000 errors = obs.get("errors", [])
1001 strat = it.get("strategy", {})
1002 rationale = strat.get("rationale", "")[:100]
1003 calls = strat.get("tool_calls", [])
1004 tool_names = [c.get("tool_name", "?") for c in calls if isinstance(c, dict)]
1005 click.echo(f" tools: {tool_names}")
1006 click.echo(f" rationale: {rationale}")
1007 click.echo(f" tool_results: {len(results)} entries, errors: {len(errors)}")
1008 else:
1009 _emit_json({"session_id": session_id, "iterations": cleaned})
1010 return
1012 summaries = [
1013 {
1014 "iteration_index": it.get("iteration_index"),
1015 "verdict": it.get("verdict"),
1016 "verdict_reason": it.get("verdict_reason"),
1017 "started_at": it.get("started_at"),
1018 "ended_at": it.get("ended_at"),
1019 "checkpoint_evaluated": it.get("checkpoint_evaluated", False),
1020 }
1021 for it in iterations
1022 if isinstance(it, Mapping)
1023 ]
1024 if output == "table":
1025 for s in summaries:
1026 click.echo(
1027 f" Iteration {s['iteration_index']}: {s['verdict']} ({s['verdict_reason']})"
1028 )
1029 else:
1030 _emit_json({"session_id": session_id, "iterations": summaries})
1033# ---------------------------------------------------------------------------
1034# list
1035# ---------------------------------------------------------------------------
1038@mission_cmd.command("list")
1039@click.option(
1040 "--status",
1041 default=None,
1042 help="Filter sessions by status (pending, running, paused, ...).",
1043)
1044@click.option(
1045 "--output",
1046 type=click.Choice(["json", "table"]),
1047 default="json",
1048 show_default=True,
1049)
1050def mission_list_cmd(status: str | None, output: str) -> None:
1051 """List Mission sessions."""
1052 from mission.state import get_backend # noqa: PLC0415
1054 backend = get_backend()
1055 filter_dict = {"status": status} if status else None
1056 sessions = backend.list_sessions(filter_dict)
1058 if output == "table":
1059 header = f" {'SESSION ID':<40} {'STATUS':<11} {'ITER':>5} CREATED"
1060 click.echo(header)
1061 click.echo(" " + "-" * (len(header) - 2))
1062 for s in sessions:
1063 sid = (s.get("session_id") or "")[:40]
1064 st = (s.get("status") or "")[:11]
1065 it = s.get("iteration_count", 0)
1066 ca = (s.get("created_at") or "")[:19]
1067 click.echo(f" {sid:<40} {st:<11} {it:>5} {ca}")
1068 else:
1069 _emit_json({"sessions": sessions})
1072# ---------------------------------------------------------------------------
1073# scaffold-criteria
1074# ---------------------------------------------------------------------------
1077@mission_cmd.command("scaffold-criteria")
1078@click.option(
1079 "--directive",
1080 required=True,
1081 help="Natural-language goal description used to seed the criteria.",
1082)
1083@click.option(
1084 "--allowlist",
1085 "allowlist",
1086 multiple=True,
1087 help=(
1088 "Optional tool names that the resulting session would be "
1089 "configured with. Used informationally on the deterministic "
1090 "path; on the sampling path, shapes the prompt so the model "
1091 "picks metric/event names plausibly produced by the listed tools."
1092 ),
1093)
1094@click.option(
1095 "--use-sampling/--no-sampling",
1096 "use_sampling",
1097 default=None,
1098 help=(
1099 "Force the sampling path on/off. Default auto-detects: MCP "
1100 "host capability, then Bedrock credentials, then deterministic."
1101 ),
1102)
1103@click.option(
1104 "--bedrock-model-id",
1105 default=None,
1106 help="Override the Bedrock model id used by the CLI sampling backend.",
1107)
1108@click.option(
1109 "--max-criteria",
1110 type=int,
1111 default=5,
1112 show_default=True,
1113 help="Cap on the number of criterion entries scaffolded.",
1114)
1115@click.option(
1116 "--retries",
1117 type=int,
1118 default=3,
1119 show_default=True,
1120 help="Sampling-path retry budget on validator rejections.",
1121)
1122@click.option(
1123 "--output-file",
1124 "output_file",
1125 type=click.Path(dir_okay=False),
1126 default=None,
1127 help="Write the JSON to this file instead of stdout.",
1128)
1129@click.option(
1130 "--output",
1131 type=click.Choice(["json", "table"]),
1132 default="json",
1133 show_default=True,
1134 help="Output format (table mode prints a per-entry summary alongside the JSON).",
1135)
1136def mission_scaffold_criteria_cmd(
1137 directive: str,
1138 allowlist: tuple[str, ...],
1139 use_sampling: bool | None,
1140 bedrock_model_id: str | None,
1141 max_criteria: int,
1142 retries: int,
1143 output_file: str | None,
1144 output: str,
1145) -> None:
1146 """Scaffold a criteria.json from a natural-language directive.
1148 Resolves the sampling state via ``mission.sampling.resolve_sampling_state``;
1149 when a backend resolves and ``--use-sampling`` permits, the resolved
1150 backend is asked for a JSON array. The response is validated through
1151 ``validate_criteria`` and retried up to ``--retries`` times on
1152 rejection. Falls back to the deterministic keyword-template
1153 generator when sampling is unavailable, disabled, or after the
1154 retry budget is exhausted.
1156 The output always validates through ``validate_criteria`` so the
1157 resulting file is immediately usable with ``mission start
1158 --criteria-file``.
1159 """
1160 import mission.criteria_scaffold as criteria_scaffold # noqa: PLC0415 — lazy: avoids cost when help-only
1161 from mission import (
1162 sampling as mission_sampling,
1163 )
1165 if max_criteria < 1:
1166 _emit_error(
1167 "validation_error",
1168 {"field": "max-criteria", "reason": "must_be_positive_int"},
1169 )
1170 sys.exit(1)
1171 if retries < 0:
1172 _emit_error(
1173 "validation_error",
1174 {"field": "retries", "reason": "must_be_non_negative_int"},
1175 )
1176 sys.exit(1)
1178 use_sampling_resolved, backend_resolved = mission_sampling.resolve_sampling_state(use_sampling)
1180 criteria: list[dict[str, Any]] | None = None
1181 sampling_path_taken = False
1182 if use_sampling_resolved and backend_resolved != "none":
1183 backend_obj = mission_sampling.select_sampling_backend(model_id=bedrock_model_id)
1184 if backend_obj is not None:
1185 try:
1186 criteria = asyncio.run(
1187 criteria_scaffold.generate_sampled_criteria(
1188 backend_obj,
1189 directive,
1190 allowlist=list(allowlist),
1191 max_criteria=max_criteria,
1192 retries=retries,
1193 )
1194 )
1195 sampling_path_taken = True
1196 except BedrockFTUFormNotAcceptedError as exc:
1197 # Not a fallback case: the account cannot invoke any Anthropic
1198 # model until the one-time form is submitted, so report it.
1199 raise click.ClickException(str(exc)) from exc
1200 except criteria_scaffold.ScaffoldSamplingError as exc:
1201 # The sampling path failed; emit a one-line warning to
1202 # stderr so the operator sees what happened, then fall
1203 # through to the deterministic generator.
1204 click.echo(
1205 f"sampling path failed ({exc.last_reason}); "
1206 "falling back to deterministic templates.",
1207 err=True,
1208 )
1209 criteria = None
1211 if criteria is None:
1212 criteria = criteria_scaffold.generate_deterministic_criteria(
1213 directive,
1214 allowlist=list(allowlist) or None,
1215 max_criteria=max_criteria,
1216 )
1218 payload = json.dumps(criteria, indent=2, sort_keys=False)
1220 if output_file:
1221 Path(output_file).write_text(payload + "\n", encoding="utf-8")
1222 # Echo a structured summary on the chosen format so the operator
1223 # can see what was written without re-reading the file.
1224 if output == "table":
1225 for c in criteria:
1226 click.echo(
1227 f" {c.get('criterion_id'):<32} "
1228 f"kind={c.get('kind'):<16} required={c.get('required')}"
1229 )
1230 click.echo(f" written to {output_file}")
1231 else:
1232 _emit_json(
1233 {
1234 "output_file": output_file,
1235 "criteria_count": len(criteria),
1236 "sampling_path": sampling_path_taken,
1237 }
1238 )
1239 return
1241 # No --output-file: write JSON to stdout.
1242 if output == "table":
1243 for c in criteria:
1244 click.echo(
1245 f" {c.get('criterion_id'):<32} "
1246 f"kind={c.get('kind'):<16} required={c.get('required')}"
1247 )
1248 return
1249 _emit_json_text(payload)
1252# ---------------------------------------------------------------------------
1253# run — chain scaffold + start + iterate-to-completion in one call
1254# ---------------------------------------------------------------------------
1257@mission_cmd.command("run")
1258@click.option(
1259 "--directive",
1260 required=True,
1261 help="Natural-language goal description.",
1262)
1263@click.option(
1264 "--tool-allowlist",
1265 multiple=True,
1266 help="Tool name to allowlist; pass multiple times. Optional with --allow-all-tools.",
1267)
1268@click.option(
1269 "--allow-all-tools",
1270 is_flag=True,
1271 help=(
1272 "Resolve the session's tool allowlist to every registered MCP tool "
1273 "(minus the mission_* control tools). Makes --tool-allowlist optional; "
1274 "mutually exclusive with it."
1275 ),
1276)
1277@click.option(
1278 "--max-iterations",
1279 type=int,
1280 default=5,
1281 show_default=True,
1282 help="Hard cap on the iteration count. Pass -1 to opt out (uncapped).",
1283)
1284@click.option(
1285 "--max-wall-clock",
1286 type=int,
1287 default=300,
1288 show_default=True,
1289 help="Hard cap on wall-clock seconds. Pass -1 to opt out (uncapped).",
1290)
1291@click.option(
1292 "--max-criteria",
1293 type=int,
1294 default=5,
1295 show_default=True,
1296 help="Cap on the number of criterion entries scaffolded.",
1297)
1298@click.option(
1299 "--retries",
1300 type=int,
1301 default=3,
1302 show_default=True,
1303 help="Sampling-path retry budget on validator rejections during scaffolding.",
1304)
1305@click.option(
1306 "--use-sampling/--no-sampling",
1307 "use_sampling",
1308 default=None,
1309 help=(
1310 "Force the sampling path on/off for both the scaffolder and "
1311 "the loop's Strategy_Revision sampler. Default auto-detects: "
1312 "MCP host capability, then Bedrock credentials, then deterministic."
1313 ),
1314)
1315@click.option(
1316 "--bedrock-model-id",
1317 default=None,
1318 help="Override the Bedrock model id used by the CLI sampling backend.",
1319)
1320@click.option(
1321 "--allow-scripted-strategies",
1322 is_flag=True,
1323 help="Allow scripted strategies to run via the Mission sandbox.",
1324)
1325@click.option(
1326 "--save-criteria",
1327 "save_criteria",
1328 type=click.Path(dir_okay=False),
1329 default=None,
1330 help="Optional path to also persist the scaffolded criteria JSON to disk.",
1331)
1332@click.option(
1333 "--stagnation-threshold",
1334 type=int,
1335 default=3,
1336 show_default=True,
1337 help="Iterations of no progress before terminate.",
1338)
1339@click.option(
1340 "--cadence",
1341 type=click.Choice(["every_iteration", "every_n_iterations", "every_t_seconds", "on_event"]),
1342 default="every_iteration",
1343 show_default=True,
1344 help="Checkpoint cadence kind.",
1345)
1346@click.option(
1347 "--dry-run",
1348 "dry_run",
1349 is_flag=True,
1350 help=(
1351 "Use a stub tool dispatcher and disable Strategy_Revision sampling "
1352 "during iteration. The criteria scaffolder still runs through "
1353 "Bedrock when sampling is enabled. Useful for smoke-testing the "
1354 "loop without spending live tool credits."
1355 ),
1356)
1357def mission_run_cmd(
1358 directive: str,
1359 tool_allowlist: tuple[str, ...],
1360 allow_all_tools: bool,
1361 max_iterations: int,
1362 max_wall_clock: int,
1363 max_criteria: int,
1364 retries: int,
1365 use_sampling: bool | None,
1366 bedrock_model_id: str | None,
1367 allow_scripted_strategies: bool,
1368 save_criteria: str | None,
1369 stagnation_threshold: int,
1370 cadence: str,
1371 dry_run: bool,
1372) -> None:
1373 """Scaffold criteria and run a Mission session to completion in one call.
1375 The chained shorthand for the most common Mission invocation: turn
1376 a natural-language directive into a criteria file via
1377 ``scaffold-criteria`` (sampling path with deterministic fallback),
1378 persist a new session with ``start``'s validators, then drive it
1379 through ``run-to-completion`` with the same per-call verdict
1380 streaming as ``mission start --run``.
1382 Per-iteration verdict updates land on stderr as JSON lines; the
1383 Final_Report (or persisted session JSON when no Final_Report file
1384 was written) lands on stdout when the loop terminates.
1386 With ``--save-criteria PATH``, the scaffolded criteria JSON is
1387 also written to ``PATH`` so the operator can inspect / re-use it
1388 without re-running the scaffold step.
1389 """
1390 import mission.criteria_scaffold as criteria_scaffold # noqa: PLC0415 — lazy
1391 from mission import (
1392 sampling as mission_sampling,
1393 )
1394 from mission import (
1395 state as mission_state,
1396 )
1397 from mission import (
1398 validation as mission_validation,
1399 )
1400 from mission.types import SCHEMA_VERSION
1401 from mission.validation import MissionValidationError
1403 if max_criteria < 1:
1404 _emit_error(
1405 "validation_error",
1406 {"field": "max-criteria", "reason": "must_be_positive_int"},
1407 )
1408 sys.exit(1)
1409 if retries < 0:
1410 _emit_error(
1411 "validation_error",
1412 {"field": "retries", "reason": "must_be_non_negative_int"},
1413 )
1414 sys.exit(1)
1416 # Resolve the effective allowlist up front, before scaffolding or any
1417 # persistence. A mutual-exclusivity or empty-registry rejection exits here
1418 # with no sampling spend, no criteria file write, and no state write. The
1419 # scaffolder below still consults the explicit ``tool_allowlist`` (empty
1420 # under --allow-all-tools, which routes it to the directive-only
1421 # deterministic path); ``allowlist_resolved`` fills the persisted session.
1422 allowlist_resolved = _resolve_cli_allowlist(
1423 allow_all_tools=allow_all_tools, tool_allowlist=tool_allowlist
1424 )
1426 # ---- Step 1: scaffold criteria. -------------------------------------
1427 # Resolve the sampling state once; reuse it for both the scaffold
1428 # call and the persisted session's ``use_sampling`` field so the
1429 # operator's --use-sampling/--no-sampling intent applies end-to-end.
1430 use_sampling_resolved, backend_resolved = mission_sampling.resolve_sampling_state(use_sampling)
1432 criteria: list[dict[str, Any]] | None = None
1433 sampling_path_taken = False
1434 if use_sampling_resolved and backend_resolved != "none":
1435 backend_obj = mission_sampling.select_sampling_backend(model_id=bedrock_model_id)
1436 if backend_obj is not None:
1437 try:
1438 criteria = asyncio.run(
1439 criteria_scaffold.generate_sampled_criteria(
1440 backend_obj,
1441 directive,
1442 allowlist=list(tool_allowlist),
1443 max_criteria=max_criteria,
1444 retries=retries,
1445 )
1446 )
1447 sampling_path_taken = True
1448 except BedrockFTUFormNotAcceptedError as exc:
1449 # Not a fallback case: the account cannot invoke any Anthropic
1450 # model until the one-time form is submitted, so report it.
1451 raise click.ClickException(str(exc)) from exc
1452 except criteria_scaffold.ScaffoldSamplingError as exc:
1453 click.echo(
1454 f"sampling path failed ({exc.last_reason}); "
1455 "falling back to deterministic templates.",
1456 err=True,
1457 )
1458 criteria = None
1460 if criteria is None:
1461 criteria = criteria_scaffold.generate_deterministic_criteria(
1462 directive,
1463 allowlist=list(tool_allowlist) or None,
1464 max_criteria=max_criteria,
1465 )
1467 if save_criteria:
1468 Path(save_criteria).write_text(
1469 json.dumps(criteria, indent=2, sort_keys=False) + "\n",
1470 encoding="utf-8",
1471 )
1473 # ---- Step 2: validate everything and persist the session. -----------
1474 budget: dict[str, Any] = {
1475 "max_iterations": max_iterations,
1476 "max_wall_clock_seconds": max_wall_clock,
1477 }
1478 cadence_dict: dict[str, Any] = {"kind": cadence}
1480 try:
1481 directive_clean = mission_validation.validate_directive(directive)
1482 criteria_clean = mission_validation.validate_criteria(criteria)
1483 budget_clean = mission_validation.validate_budget(budget, allowlist_resolved, {})
1484 cadence_clean = mission_validation.validate_cadence(cadence_dict)
1485 except MissionValidationError as exc:
1486 _emit_error(exc.code, exc.details)
1487 sys.exit(1)
1489 if not isinstance(stagnation_threshold, int) or stagnation_threshold <= 0:
1490 _emit_error(
1491 "validation_error",
1492 {"field": "stagnation-threshold", "reason": "must_be_positive_int"},
1493 )
1494 sys.exit(1)
1496 session_id = f"mission-{secrets.token_hex(8)}"
1497 now_iso = datetime.now(UTC).isoformat()
1498 session: dict[str, Any] = {
1499 "version": SCHEMA_VERSION,
1500 "session_id": session_id,
1501 "directive_text": directive_clean,
1502 "criteria": criteria_clean,
1503 "budget": budget_clean,
1504 "tool_allowlist": allowlist_resolved,
1505 "checkpoint_cadence": cadence_clean,
1506 "stagnation_threshold": stagnation_threshold,
1507 "use_sampling": use_sampling_resolved,
1508 "sampling_backend_resolved": backend_resolved,
1509 "allow_scripted_strategies": bool(allow_scripted_strategies),
1510 "status": "pending",
1511 "created_at": now_iso,
1512 "iterations": [],
1513 "no_progress_counter": 0,
1514 }
1515 if bedrock_model_id:
1516 session["bedrock_model_id"] = bedrock_model_id
1518 backend = mission_state.get_backend()
1519 backend.save_session(cast("SessionState", _strip_private_criteria(session)))
1521 # Emit a one-line scaffold summary to stderr so the operator can see
1522 # what shape the criteria landed in before the loop starts. Stdout is
1523 # reserved for the Final_Report at the end.
1524 _emit_json(
1525 {
1526 "event": "mission.run.scaffolded",
1527 "session_id": session_id,
1528 "criteria_count": len(criteria),
1529 "sampling_path": sampling_path_taken,
1530 "sampling_backend_resolved": backend_resolved,
1531 },
1532 err=True,
1533 )
1535 # ---- Step 3: iterate to completion. ---------------------------------
1536 _run_to_completion(session_id, dry_run=dry_run)
1539# ---------------------------------------------------------------------------
1540# memory
1541# ---------------------------------------------------------------------------
1543_MEMORY_UNAVAILABLE_HINT = (
1544 "Mission memory is not available. The table and vector index ship with the "
1545 "global stack (mission_memory.enabled in cdk.json, on by default): run "
1546 "'gco stacks deploy gco-global', or wait for the vector index to finish "
1547 "backfilling after the first deployment. See docs/MISSION.md."
1548)
1551def _build_memory_store() -> Any:
1552 """Construct the mission-memory store (SSM-lazy; free until first use)."""
1553 from mission.memory import MissionMemoryStore # noqa: PLC0415
1555 return MissionMemoryStore()
1558def _exit_memory_unavailable(err: Exception) -> None:
1559 """Print the deployment hint and a structured envelope, then exit 1."""
1560 click.echo(_MEMORY_UNAVAILABLE_HINT, err=True)
1561 _emit_error("mission_memory_unavailable", {"message": str(err)})
1562 raise SystemExit(1)
1565@mission_cmd.group("memory")
1566def mission_memory_cmd() -> None:
1567 """Institutional memory across Mission sessions.
1569 Completed missions are embedded into the ``{project}-mission-memory``
1570 DynamoDB vector index; these subcommands search it (``search``),
1571 list what it holds (``list``), and seed it from existing
1572 Final_Reports (``backfill``). Requires the mission-memory add-on
1573 deployed with the global stack.
1574 """
1577@mission_memory_cmd.command("search")
1578@click.argument("directive")
1579@click.option(
1580 "--top-k",
1581 type=int,
1582 default=3,
1583 show_default=True,
1584 help="Number of similar past missions to return.",
1585)
1586@click.option(
1587 "--verdict",
1588 type=click.Choice(["complete", "terminate"]),
1589 default=None,
1590 help="Only return missions that ended with this terminal verdict.",
1591)
1592@click.option(
1593 "--output",
1594 type=click.Choice(["json", "table"]),
1595 default="json",
1596 show_default=True,
1597)
1598def mission_memory_search_cmd(directive: str, top_k: int, verdict: str | None, output: str) -> None:
1599 """Search mission memory for missions similar to DIRECTIVE."""
1600 from mission.memory import MissionMemoryUnavailableError # noqa: PLC0415
1602 try:
1603 results = _build_memory_store().search_similar(
1604 directive, top_k=top_k, final_verdict=verdict
1605 )
1606 except MissionMemoryUnavailableError as err:
1607 _exit_memory_unavailable(err)
1608 except Exception as err: # noqa: BLE001 — CLI boundary: envelope, don't traceback
1609 _emit_error("mission_memory_search_failed", {"message": str(err)})
1610 raise SystemExit(1) from None
1612 if output == "table":
1613 header = f" {'SCORE':>6} {'SESSION ID':<40} {'VERDICT':<9} DIRECTIVE"
1614 click.echo(header)
1615 click.echo(" " + "-" * (len(header) - 2))
1616 for entry in results:
1617 score = entry.get("score")
1618 score_text = f"{score:.3f}" if isinstance(score, (int, float)) else "-"
1619 sid = (entry.get("session_id") or "")[:40]
1620 fv = (entry.get("final_verdict") or "")[:9]
1621 dt = entry.get("directive") or ""
1622 click.echo(f" {score_text:>6} {sid:<40} {fv:<9} {dt}")
1623 else:
1624 _emit_json({"results": results})
1627@mission_memory_cmd.command("list")
1628@click.option(
1629 "--limit",
1630 type=int,
1631 default=50,
1632 show_default=True,
1633 help="Maximum memory items to return (newest completion first).",
1634)
1635@click.option(
1636 "--output",
1637 type=click.Choice(["json", "table"]),
1638 default="json",
1639 show_default=True,
1640)
1641def mission_memory_list_cmd(limit: int, output: str) -> None:
1642 """List what mission memory currently holds."""
1643 from mission.memory import MissionMemoryUnavailableError # noqa: PLC0415
1645 try:
1646 memories = _build_memory_store().list_memories(limit=limit)
1647 except MissionMemoryUnavailableError as err:
1648 _exit_memory_unavailable(err)
1649 except Exception as err: # noqa: BLE001 — CLI boundary: envelope, don't traceback
1650 _emit_error("mission_memory_list_failed", {"message": str(err)})
1651 raise SystemExit(1) from None
1653 if output == "table":
1654 header = f" {'COMPLETED':<25} {'SESSION ID':<40} {'VERDICT':<9} {'ITER':>5} DIRECTIVE"
1655 click.echo(header)
1656 click.echo(" " + "-" * (len(header) - 2))
1657 for entry in memories:
1658 ca = (entry.get("completed_at") or "")[:25]
1659 sid = (entry.get("session_id") or "")[:40]
1660 fv = (entry.get("final_verdict") or "")[:9]
1661 it = entry.get("iteration_count", 0)
1662 dt = entry.get("directive") or ""
1663 click.echo(f" {ca:<25} {sid:<40} {fv:<9} {it:>5} {dt}")
1664 else:
1665 _emit_json({"memories": memories})
1668@mission_memory_cmd.command("backfill")
1669@click.option(
1670 "--root",
1671 type=click.Path(file_okay=False),
1672 default=None,
1673 help=(
1674 "Directory holding *.report.json Final_Reports to embed. Defaults "
1675 "to the filesystem mission root (~/.gco/missions)."
1676 ),
1677)
1678def mission_memory_backfill_cmd(root: str | None) -> None:
1679 """Seed mission memory from existing Final_Reports.
1681 Reads every ``*.report.json`` under the report root, embeds each
1682 report's directive, and writes one memory item per terminal report
1683 — so memory is useful on day one instead of accumulating from zero.
1684 Re-running is safe: writes are keyed on ``session_id`` and simply
1685 overwrite (re-embedding the same directive).
1686 """
1687 from mission.memory import MissionMemoryUnavailableError # noqa: PLC0415
1688 from mission.state import FilesystemBackend # noqa: PLC0415
1690 report_root = Path(root) if root is not None else FilesystemBackend().root
1691 reports = sorted(report_root.glob("*.report.json"))
1692 store = _build_memory_store()
1694 written = 0
1695 skipped = 0
1696 failures: list[dict[str, str]] = []
1697 for report_path in reports:
1698 try:
1699 report = json.loads(report_path.read_text(encoding="utf-8"))
1700 except (OSError, UnicodeError, json.JSONDecodeError) as err:
1701 failures.append({"file": report_path.name, "error": f"unreadable report: {err}"})
1702 continue
1703 verdict = str(report.get("final_verdict") or "") if isinstance(report, dict) else ""
1704 if (
1705 not isinstance(report, dict)
1706 or not report.get("session_id")
1707 or not str(report.get("directive_text") or "").strip()
1708 or verdict not in ("complete", "terminate")
1709 ):
1710 # Not a terminal Final_Report shape — count it, don't fail it.
1711 skipped += 1
1712 continue
1713 try:
1714 store.write_memory(
1715 report,
1716 verdict,
1717 str(report.get("final_verdict_reason") or ""),
1718 str(report.get("lessons") or ""),
1719 [str(item) for item in report.get("recommended_followups") or []],
1720 )
1721 written += 1
1722 except MissionMemoryUnavailableError as err:
1723 # Infrastructure absent: no later report can succeed either.
1724 _exit_memory_unavailable(err)
1725 except Exception as err: # noqa: BLE001 — per-report isolation
1726 failures.append({"file": report_path.name, "error": str(err)})
1728 payload: dict[str, Any] = {
1729 "written": written,
1730 "skipped": skipped,
1731 "failed": len(failures),
1732 "root": str(report_root),
1733 }
1734 if failures:
1735 payload["failures"] = failures
1736 _emit_json(payload)
1737 if failures:
1738 raise SystemExit(1)