froid-loop 0.11.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (116) hide show
  1. froid_loop/__init__.py +11 -0
  2. froid_loop/__main__.py +12 -0
  3. froid_loop/adapters/__init__.py +3 -0
  4. froid_loop/adapters/base.py +254 -0
  5. froid_loop/adapters/entrypoints.py +63 -0
  6. froid_loop/adapters/env_fault.py +290 -0
  7. froid_loop/adapters/generic.py +2013 -0
  8. froid_loop/adapters/mock.py +49 -0
  9. froid_loop/adapters/multiplexer.py +914 -0
  10. froid_loop/adapters/opencode_http.py +1687 -0
  11. froid_loop/adapters/profile.py +650 -0
  12. froid_loop/adapters/psmux_backend.py +1428 -0
  13. froid_loop/adapters/registry.py +322 -0
  14. froid_loop/adapters/tmux_backend.py +35 -0
  15. froid_loop/adapters/tmux_base.py +630 -0
  16. froid_loop/checks.py +187 -0
  17. froid_loop/cli.py +5041 -0
  18. froid_loop/data/__init__.py +0 -0
  19. froid_loop/data/froid_loop_hook.py +228 -0
  20. froid_loop/data/froid_loop_probe_hook.py +88 -0
  21. froid_loop/data/plugins/example/plugin.toml +21 -0
  22. froid_loop/data/plugins/tea/plugin.toml +184 -0
  23. froid_loop/data/plugins/tea/tea_plugin.py +258 -0
  24. froid_loop/data/plugins/unity/plugin.toml +140 -0
  25. froid_loop/data/plugins/unity/unity_assets/FroidLoop.Unity.Editor.asmdef +16 -0
  26. froid_loop/data/plugins/unity/unity_assets/FroidLoop.Unity.Editor.asmdef.meta +7 -0
  27. froid_loop/data/plugins/unity/unity_assets/SceneAutoSaveGuard.cs +221 -0
  28. froid_loop/data/plugins/unity/unity_assets/SceneAutoSaveGuard.cs.meta +11 -0
  29. froid_loop/data/plugins/unity/unity_assets/_folders/Editor.meta +8 -0
  30. froid_loop/data/plugins/unity/unity_assets/_folders/FroidLoop.meta +8 -0
  31. froid_loop/data/plugins/unity/unity_cleanup.py +125 -0
  32. froid_loop/data/plugins/unity/unity_dialog_probe.py +239 -0
  33. froid_loop/data/plugins/unity/unity_facts.md +17 -0
  34. froid_loop/data/plugins/unity/unity_plugin.py +415 -0
  35. froid_loop/data/plugins/unity/unity_quiesce.py +234 -0
  36. froid_loop/data/plugins/unity/unity_ready.py +230 -0
  37. froid_loop/data/plugins/unity/unity_seed_assets.py +298 -0
  38. froid_loop/data/plugins/unity/unity_setup.py +551 -0
  39. froid_loop/data/plugins/unity/unity_teardown.py +362 -0
  40. froid_loop/data/profiles/antigravity.toml +52 -0
  41. froid_loop/data/profiles/claude.toml +85 -0
  42. froid_loop/data/profiles/codex.toml +22 -0
  43. froid_loop/data/profiles/copilot.toml +52 -0
  44. froid_loop/data/profiles/gemini.toml +26 -0
  45. froid_loop/data/profiles/opencode.toml +54 -0
  46. froid_loop/data/settings/core.toml +458 -0
  47. froid_loop/data/skills/README.md +93 -0
  48. froid_loop/data/skills/froid-loop-resolve/SKILL.md +288 -0
  49. froid_loop/data/skills/froid-loop-setup/SKILL.md +161 -0
  50. froid_loop/data/skills/froid-loop-setup/assets/module-help.csv +3 -0
  51. froid_loop/data/skills/froid-loop-setup/assets/module.yaml +19 -0
  52. froid_loop/data/skills/froid-loop-sweep/SKILL.md +100 -0
  53. froid_loop/data/skills/froid-loop-sweep/automation-mode.md +127 -0
  54. froid_loop/data/skills/froid-loop-sweep/deferred-work-format.md +302 -0
  55. froid_loop/data/skills/froid-loop-sweep/migration-mode.md +86 -0
  56. froid_loop/decisions.py +202 -0
  57. froid_loop/deferredwork.py +2282 -0
  58. froid_loop/devcontract.py +892 -0
  59. froid_loop/diagnostics.py +1104 -0
  60. froid_loop/documents.py +532 -0
  61. froid_loop/engine.py +7732 -0
  62. froid_loop/envvars.py +111 -0
  63. froid_loop/escalation.py +225 -0
  64. froid_loop/events.py +266 -0
  65. froid_loop/fences.py +103 -0
  66. froid_loop/froidconfig.py +226 -0
  67. froid_loop/frontmatter.py +526 -0
  68. froid_loop/gates.py +133 -0
  69. froid_loop/install.py +2936 -0
  70. froid_loop/journal.py +178 -0
  71. froid_loop/machine.py +148 -0
  72. froid_loop/model.py +898 -0
  73. froid_loop/operatoractions.py +474 -0
  74. froid_loop/platform_util.py +1490 -0
  75. froid_loop/plugins/__init__.py +64 -0
  76. froid_loop/plugins/bus.py +259 -0
  77. froid_loop/plugins/context.py +319 -0
  78. froid_loop/plugins/loader.py +145 -0
  79. froid_loop/plugins/manifest.py +279 -0
  80. froid_loop/plugins/model.py +296 -0
  81. froid_loop/plugins/registry.py +245 -0
  82. froid_loop/plugins/trust.py +75 -0
  83. froid_loop/policy.py +1569 -0
  84. froid_loop/probe.py +1044 -0
  85. froid_loop/process_host.py +408 -0
  86. froid_loop/recovery_flow.py +1561 -0
  87. froid_loop/resolve.py +283 -0
  88. froid_loop/runs.py +4715 -0
  89. froid_loop/runsetup.py +1293 -0
  90. froid_loop/sanitize.py +593 -0
  91. froid_loop/settings_schema.py +276 -0
  92. froid_loop/signals.py +160 -0
  93. froid_loop/sprintstatus.py +609 -0
  94. froid_loop/statemachine.py +57 -0
  95. froid_loop/stories.py +615 -0
  96. froid_loop/stories_engine.py +796 -0
  97. froid_loop/sweep.py +1892 -0
  98. froid_loop/tokens.py +196 -0
  99. froid_loop/tui/__init__.py +11 -0
  100. froid_loop/tui/app.py +1584 -0
  101. froid_loop/tui/data.py +840 -0
  102. froid_loop/tui/launch.py +1003 -0
  103. froid_loop/tui/screens/__init__.py +1 -0
  104. froid_loop/tui/screens/dashboard.py +1071 -0
  105. froid_loop/tui/screens/modals.py +943 -0
  106. froid_loop/tui/screens/settings_screen.py +477 -0
  107. froid_loop/tui/settings.py +135 -0
  108. froid_loop/tui/widgets.py +981 -0
  109. froid_loop/verify.py +4545 -0
  110. froid_loop/workspace.py +320 -0
  111. froid_loop/worktree_flow.py +2301 -0
  112. froid_loop-0.11.1.dist-info/METADATA +728 -0
  113. froid_loop-0.11.1.dist-info/RECORD +116 -0
  114. froid_loop-0.11.1.dist-info/WHEEL +4 -0
  115. froid_loop-0.11.1.dist-info/entry_points.txt +2 -0
  116. froid_loop-0.11.1.dist-info/licenses/LICENSE +30 -0
@@ -0,0 +1,64 @@
1
+ """froid-loop plugin system.
2
+
3
+ A first-class, general extension layer for the orchestrator: a plugin extends
4
+ the froid_loop without modifying core, ranging from a data-only settings
5
+ contribution to an in-process Python module. Distribution is folder-drop today
6
+ (builtins under ``froid_loop/data/plugins/``, project-local under
7
+ ``.froid-loop/plugins/``) with a locked seam for entry-point packaging later.
8
+
9
+ Phase 0 ships the foundation — manifest model, loader, trust gate, registry —
10
+ wired into nothing yet. The hook bus, dynamic settings, and the engine migration
11
+ build on this in later phases.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from .bus import HookBus
17
+ from .context import VETO_ACTIONS, HookContext, Veto
18
+ from .loader import (
19
+ ENTRY_POINT_GROUP,
20
+ USER_PLUGINS_REL,
21
+ discover,
22
+ get_plugin,
23
+ load_plugins,
24
+ )
25
+ from .model import (
26
+ API_VERSION,
27
+ SETTING_TYPES,
28
+ SUPPORTED_API,
29
+ HookSpec,
30
+ LoadedPlugin,
31
+ Plugin,
32
+ PluginError,
33
+ PluginManifest,
34
+ PythonSpec,
35
+ SettingSpec,
36
+ )
37
+ from .registry import PluginRegistry
38
+ from .trust import PluginUntrusted, is_enabled, require_enabled
39
+
40
+ __all__ = [
41
+ "API_VERSION",
42
+ "SUPPORTED_API",
43
+ "SETTING_TYPES",
44
+ "ENTRY_POINT_GROUP",
45
+ "USER_PLUGINS_REL",
46
+ "HookSpec",
47
+ "SettingSpec",
48
+ "PythonSpec",
49
+ "PluginManifest",
50
+ "Plugin",
51
+ "LoadedPlugin",
52
+ "PluginError",
53
+ "PluginUntrusted",
54
+ "PluginRegistry",
55
+ "HookBus",
56
+ "HookContext",
57
+ "Veto",
58
+ "VETO_ACTIONS",
59
+ "discover",
60
+ "load_plugins",
61
+ "get_plugin",
62
+ "is_enabled",
63
+ "require_enabled",
64
+ ]
@@ -0,0 +1,259 @@
1
+ """HookBus: dispatch lifecycle stages to plugin hooks (declarative + python).
2
+
3
+ The bus is the single fan-out point between the engine's stages and the loaded
4
+ plugins. It enforces the two invariants that keep plugins safe:
5
+
6
+ * **No-op fast path.** ``active(stage)`` is an O(1) set membership test
7
+ precomputed at build time. A run with no plugin bound to a stage never
8
+ builds a context or calls ``emit`` — zero-plugin runs stay byte-identical.
9
+ * **Failure isolation.** Every hook — subprocess or in-process Python — is
10
+ wrapped. A python hook that raises is caught (``except Exception`` only;
11
+ ``RunStopped``/SIGTERM as ``BaseException`` propagate), journalled, and the
12
+ offending instance disabled for the rest of the run. A declarative hook that
13
+ errors (timeout, bad interpreter) fails open by default (the run survives);
14
+ ``fail_closed`` turns an error into a defer veto.
15
+
16
+ Dispatch order is registry order (manifest ``priority`` then load order).
17
+ Mutations pipeline — a later plugin sees an earlier plugin's edits. Vetoes are
18
+ collected without short-circuit so order can never hide a severer objection;
19
+ the context resolves the most-conservative one.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import json
25
+ import os
26
+ import subprocess
27
+ from typing import Any, Callable
28
+
29
+ from .context import MUTABLE_FIELDS, VETO_ACTIONS, HookContext, Veto
30
+ from .model import LoadedPlugin
31
+ from .registry import PluginRegistry
32
+
33
+ # (returncode, combined-output) — the declarative-hook transport. Injectable so
34
+ # tests can drive the bus without spawning real subprocesses.
35
+ HookRunner = Callable[..., "tuple[int, str]"]
36
+
37
+
38
+ class _HookError(Exception):
39
+ """A declarative hook could not run to completion (timeout / launch failure),
40
+ as distinct from a clean non-zero exit. Decides fail-open vs fail-closed."""
41
+
42
+
43
+ def _run_subprocess(
44
+ cmd: str, *, cwd: str | None, env: dict[str, str], timeout: int
45
+ ) -> tuple[int, str]:
46
+ """Default declarative-hook transport: a shell command with the plugin env,
47
+ capturing output. shell=True is intentional (mirrors the deterministic verify
48
+ commands + the legacy engine ``*_cmd`` hooks).
49
+
50
+ Output decodes with ``errors="replace"`` (#383): a hook's output is arbitrary
51
+ operator-tool text whose bytes are not ours to constrain, and a strict decode
52
+ raised ``UnicodeDecodeError`` — a ``ValueError``, named by neither ``except``
53
+ arm below — which escaped ``_HookError``, the bus's designed
54
+ transport-failure channel, and crashed the run, losing even a passing hook's
55
+ exit code. Same fix and reasoning as ``verify.run_verify_commands`` (#378)."""
56
+ try:
57
+ proc = subprocess.run( # nosec B602 - operator-authored plugin command
58
+ cmd,
59
+ shell=True, # portability: operator-authored plugin command — sanctioned shell-out (see plan out-of-scope)
60
+ cwd=cwd,
61
+ env=env,
62
+ capture_output=True,
63
+ text=True,
64
+ errors="replace",
65
+ timeout=timeout,
66
+ )
67
+ except subprocess.TimeoutExpired as e:
68
+ raise _HookError(f"timed out after {timeout}s") from e
69
+ except OSError as e:
70
+ raise _HookError(str(e)) from e
71
+ return proc.returncode, proc.stdout + proc.stderr
72
+
73
+
74
+ def _instance_stages(instance: Any) -> set[str]:
75
+ """The stages an in-process plugin handles: every ``on_<stage>`` method it
76
+ defines. Lets ``active()`` stay precise — a python plugin only marks the
77
+ stages it actually implements, so the fast path holds for the rest."""
78
+ return {
79
+ name[3:]
80
+ for name in dir(instance)
81
+ if name.startswith("on_") and callable(getattr(instance, name, None))
82
+ }
83
+
84
+
85
+ def _hook_env(ctx: HookContext, lp: LoadedPlugin) -> dict[str, str]:
86
+ """The ``FROID_LOOP_*`` environment a declarative hook reads — the run's
87
+ identity fields plus the plugin's resolved settings as ``FROID_LOOP_SETTING_*``.
88
+ Generalizes ``engine._run_engine_hook``'s env block to any plugin/stage."""
89
+ env = dict(os.environ)
90
+ fields = {
91
+ "FROID_LOOP_STAGE": ctx.stage,
92
+ "FROID_LOOP_RUN_ID": ctx.run_id,
93
+ "FROID_LOOP_RUN_DIR": ctx.run_dir or "",
94
+ "FROID_LOOP_REPO_ROOT": ctx.repo_root or "",
95
+ "FROID_LOOP_WORKTREE": ctx.worktree or ctx.repo_root or "",
96
+ "FROID_LOOP_STORY_KEY": ctx.story_key or "",
97
+ "FROID_LOOP_ROLE": ctx.role or "",
98
+ "FROID_LOOP_PHASE": ctx.phase or "",
99
+ "FROID_LOOP_BRANCH": ctx.branch or "",
100
+ "FROID_LOOP_AGENTS": ",".join(ctx.agents),
101
+ "FROID_LOOP_PLUGIN": lp.name,
102
+ }
103
+ env.update({k: v for k, v in fields.items() if v != ""})
104
+ # the plugin's *resolved* settings (defaults overlaid by [plugins.<name>]),
105
+ # not bare manifest defaults, so a declarative hook sees the operator's config.
106
+ for key, value in lp.settings.items():
107
+ env[f"FROID_LOOP_SETTING_{key.upper()}"] = str(value)
108
+ return env
109
+
110
+
111
+ def _last_json(output: str) -> dict[str, Any] | None:
112
+ """A declarative hook may emit a JSON object on its last non-empty stdout
113
+ line to mutate the context. Anything else is treated as plain advisory log
114
+ text. Parse failures are ignored (the hook still ran)."""
115
+ for line in reversed(output.splitlines()):
116
+ line = line.strip()
117
+ if not line:
118
+ continue
119
+ if line.startswith("{") and line.endswith("}"):
120
+ try:
121
+ payload = json.loads(line)
122
+ except json.JSONDecodeError:
123
+ return None
124
+ return payload if isinstance(payload, dict) else None
125
+ return None
126
+ return None
127
+
128
+
129
+ class HookBus:
130
+ """Fan stage emits out to every loaded plugin. Build once per run from the
131
+ registry; share with the engine."""
132
+
133
+ def __init__(
134
+ self,
135
+ registry: PluginRegistry,
136
+ journal: Any = None,
137
+ *,
138
+ runner: HookRunner | None = None,
139
+ ):
140
+ self._registry = registry
141
+ self._journal = journal
142
+ self._runner = runner or _run_subprocess
143
+ # plugins disabled mid-run after an in-process hook raised (failure
144
+ # isolation): skipped for every subsequent stage.
145
+ self._disabled: set[str] = set()
146
+ # precompute the active-stage set for the O(1) fast path.
147
+ self._active: set[str] = set()
148
+ self._py_stages: dict[str, set[str]] = {}
149
+ for lp in registry.plugins():
150
+ for hook in lp.manifest.hooks:
151
+ self._active.add(hook.stage)
152
+ if lp.instance is not None:
153
+ stages = _instance_stages(lp.instance)
154
+ if stages:
155
+ self._py_stages[lp.name] = stages
156
+ self._active.update(stages)
157
+
158
+ # ----------------------------------------------------------- fast path
159
+
160
+ def active(self, stage: str) -> bool:
161
+ """True iff some loaded plugin binds ``stage``. The engine guards every
162
+ emit with this so a zero-plugin run does no work."""
163
+ return stage in self._active
164
+
165
+ def any_active(self) -> bool:
166
+ return bool(self._active)
167
+
168
+ def active_plugins(self) -> list[str]:
169
+ """Names of plugins that bind at least one stage — for a one-line
170
+ run-start journal entry when (and only when) plugins are live."""
171
+ out: list[str] = []
172
+ for lp in self._registry.plugins():
173
+ if lp.manifest.hooks or lp.name in self._py_stages:
174
+ out.append(lp.name)
175
+ return out
176
+
177
+ # -------------------------------------------------------------- dispatch
178
+
179
+ def emit(self, stage: str, ctx: HookContext) -> HookContext:
180
+ """Dispatch ``stage`` to every plugin bound to it, in registry order.
181
+ Returns the same context (carrying mutations + collected vetoes). A no-op
182
+ when no plugin binds the stage."""
183
+ if stage not in self._active:
184
+ return ctx
185
+ for lp in self._registry.plugins():
186
+ if lp.name in self._disabled:
187
+ continue
188
+ hook = lp.manifest.hook_for(stage)
189
+ if hook is not None:
190
+ self._dispatch_declarative(lp, hook, ctx)
191
+ if lp.instance is not None and stage in self._py_stages.get(lp.name, ()):
192
+ self._dispatch_python(lp, stage, ctx)
193
+ return ctx
194
+
195
+ def _dispatch_declarative(self, lp: LoadedPlugin, hook: Any, ctx: HookContext) -> None:
196
+ cmd = lp.manifest.render(hook.cmd)
197
+ env = _hook_env(ctx, lp)
198
+ cwd = ctx.worktree or ctx.repo_root or None
199
+ try:
200
+ rc, output = self._runner(cmd, cwd=cwd, env=env, timeout=hook.timeout_sec)
201
+ except _HookError as e:
202
+ self._log("plugin-hook-error", plugin=lp.name, stage=hook.stage, error=str(e))
203
+ if hook.blocking and hook.fail_closed:
204
+ ctx.add_veto(
205
+ Veto("defer", f"plugin {lp.name!r} hook {hook.stage} errored: {e}", lp.name)
206
+ )
207
+ return
208
+ explicit_veto = self._apply_stdout(lp, ctx, output)
209
+ if not hook.blocking:
210
+ self._log("plugin-hook", plugin=lp.name, stage=hook.stage, rc=rc)
211
+ return
212
+ # blocking hook: a non-zero exit vetoes (defer) unless the hook already
213
+ # asked for a specific action via its stdout JSON.
214
+ if rc != 0 and not explicit_veto:
215
+ tail = output.strip()[-500:]
216
+ ctx.add_veto(
217
+ Veto("defer", f"plugin {lp.name!r} hook {hook.stage} exited {rc}: {tail}", lp.name)
218
+ )
219
+ self._log("plugin-hook", plugin=lp.name, stage=hook.stage, rc=rc, blocking=True)
220
+
221
+ def _apply_stdout(self, lp: LoadedPlugin, ctx: HookContext, output: str) -> bool:
222
+ """Merge a declarative hook's optional stdout-JSON: ``shared`` updates,
223
+ whitelisted ``mutate`` fields, and an explicit ``veto``. Returns whether
224
+ an explicit veto was supplied (so the exit-code path doesn't double-veto)."""
225
+ payload = _last_json(output)
226
+ if not payload:
227
+ return False
228
+ shared = payload.get("shared")
229
+ if isinstance(shared, dict):
230
+ ctx.shared.update(shared)
231
+ mutate = payload.get("mutate")
232
+ if isinstance(mutate, dict):
233
+ for key, value in mutate.items():
234
+ if key in MUTABLE_FIELDS:
235
+ setattr(ctx, key, value)
236
+ veto = payload.get("veto")
237
+ if isinstance(veto, dict) and veto.get("action") in VETO_ACTIONS:
238
+ ctx.add_veto(Veto(veto["action"], str(veto.get("reason", "")), lp.name))
239
+ return True
240
+ return False
241
+
242
+ def _dispatch_python(self, lp: LoadedPlugin, stage: str, ctx: HookContext) -> None:
243
+ instance = lp.instance
244
+ ctx._current_plugin = lp.name
245
+ try:
246
+ instance.hook(stage, ctx) # type: ignore[union-attr]
247
+ except Exception as e: # isolate plugin failures; never BaseException
248
+ self._log("plugin-error", plugin=lp.name, stage=stage, error=f"{type(e).__name__}: {e}")
249
+ # disable the misbehaving instance for the rest of the run; its
250
+ # declarative hooks (if any) keep working — they are out-of-process.
251
+ self._disabled.add(lp.name)
252
+ if getattr(instance, "fail_closed", False):
253
+ ctx.add_veto(Veto("defer", f"plugin {lp.name!r} hook {stage} raised: {e}", lp.name))
254
+ finally:
255
+ ctx._current_plugin = ""
256
+
257
+ def _log(self, kind: str, **fields: Any) -> None:
258
+ if self._journal is not None:
259
+ self._journal.append(kind, **fields)
@@ -0,0 +1,319 @@
1
+ """HookContext + Veto: the per-stage object the hook bus hands every plugin.
2
+
3
+ A ``HookContext`` is the shared run context for one lifecycle stage. It carries:
4
+
5
+ * **read-only** facts about where the run is (identity/git fields, the current
6
+ phase/role/attempt, a *copy* of the session result, etc.) — exposed as
7
+ properties with no setter so a plugin can observe but never rewrite history;
8
+ * a **per-stage mutable whitelist** (``proposed_prompt``/``proposed_env`` for a
9
+ session, ``proposed_commit_message`` for a commit, ``proposed_feedback``,
10
+ ``proposed_decision``) the engine reads back after dispatch and applies;
11
+ * a free-form ``shared`` dict that persists across stages (the engine backs it
12
+ with ``RunState.plugin_shared`` so it survives pause/resume).
13
+
14
+ Veto is collect-then-resolve-most-conservative: every plugin that objects to a
15
+ stage appends a ``Veto``; the bus never short-circuits, so load order can never
16
+ hide a severer veto. ``resolved_veto()`` returns the single most-conservative one
17
+ (``skip`` < ``defer`` < ``pause``). The engine maps it onto its *existing*
18
+ control flow — there is no new abort path.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import copy
24
+ from dataclasses import dataclass
25
+ from typing import TYPE_CHECKING, Any
26
+
27
+ if TYPE_CHECKING:
28
+ # Type-only, exactly as `model.py` imports `HookContext`: the concrete
29
+ # verifier record type belongs in the signature, but importing it for real
30
+ # would be this package's SECOND core import (after manifest.py ->
31
+ # platform_util) and would point `plugins/` at the engine's I/O layer. There
32
+ # is no cycle today — `verify` reaches deferredwork/froidconfig/frontmatter/
33
+ # model/platform_util/policy/sprintstatus, none of which touch `plugins/` —
34
+ # so this is layering, not a workaround.
35
+ from ..verify import CommandResult
36
+
37
+ # Veto actions, least to most conservative. `skip` drops the current unit and
38
+ # continues the loop; `defer` routes through the engine's defer primitive; `pause`
39
+ # escalates (raises RunPaused). Severity orders a multi-plugin resolve.
40
+ VETO_ACTIONS = ("skip", "defer", "pause")
41
+ _VETO_SEVERITY = {"skip": 1, "defer": 2, "pause": 3}
42
+
43
+ # The mutable whitelist. A declarative hook's stdout `mutate` block and a python
44
+ # hook may only assign these; everything else on the context is read-only.
45
+ MUTABLE_FIELDS = frozenset(
46
+ {
47
+ "proposed_prompt",
48
+ "proposed_env",
49
+ "proposed_feedback",
50
+ "proposed_commit_message",
51
+ "proposed_decision",
52
+ }
53
+ )
54
+
55
+
56
+ @dataclass(frozen=True)
57
+ class Veto:
58
+ """One plugin's objection to a stage. ``action`` is in VETO_ACTIONS;
59
+ ``plugin_id`` records who raised it for the journal + escalation message."""
60
+
61
+ action: str
62
+ reason: str = ""
63
+ plugin_id: str = "?"
64
+
65
+ def __post_init__(self) -> None:
66
+ if self.action not in VETO_ACTIONS:
67
+ raise ValueError(f"veto action must be one of {VETO_ACTIONS}: got {self.action!r}")
68
+
69
+
70
+ class HookContext:
71
+ """Mutable run context for a single stage emit. The bus dispatches it to
72
+ every plugin bound to the stage; the engine applies the whitelisted
73
+ mutations + resolves the veto afterwards."""
74
+
75
+ def __init__(
76
+ self,
77
+ stage: str,
78
+ *,
79
+ run_id: str = "",
80
+ story_key: str | None = None,
81
+ epic: int | None = None,
82
+ phase: str | None = None,
83
+ attempt: int | None = None,
84
+ role: str | None = None,
85
+ worktree: str | None = None,
86
+ branch: str | None = None,
87
+ repo_root: str | None = None,
88
+ run_dir: str | None = None,
89
+ agents: tuple[str, ...] = (),
90
+ result_json: dict[str, Any] | None = None,
91
+ session_status: str | None = None,
92
+ verify_reason: str | None = None,
93
+ command_results: tuple[CommandResult, ...] = (),
94
+ verification_stage: str | None = None,
95
+ verification_sequence: int | None = None,
96
+ decision_action: str | None = None,
97
+ settings: dict[str, Any] | None = None,
98
+ shared: dict[str, Any] | None = None,
99
+ proposed_prompt: str | None = None,
100
+ proposed_env: dict[str, str] | None = None,
101
+ proposed_feedback: str | None = None,
102
+ proposed_commit_message: str | None = None,
103
+ proposed_decision: str | None = None,
104
+ ):
105
+ self._stage = stage
106
+ self._run_id = run_id
107
+ self._story_key = story_key
108
+ self._epic = epic
109
+ self._phase = phase
110
+ self._attempt = attempt
111
+ self._role = role
112
+ self._worktree = worktree
113
+ self._branch = branch
114
+ self._repo_root = repo_root
115
+ self._run_dir = run_dir
116
+ # the agent ids of the CLIs that run in this unit's worktree (dev + review),
117
+ # for a plugin that routes per-agent config (e.g. the engine's MCP routing).
118
+ self._agents = tuple(agents)
119
+ # A *deep* copy, and the depth is the whole point. `dict()` is shallow, so
120
+ # the nested `escalations` list stayed SHARED with the engine's own
121
+ # `result.result_json` — and both verify legs now emit `post_dev_verify`
122
+ # ahead of their `critical_escalations` audit (dev via `decide_dev`, fix
123
+ # at the reordered call in `_fix_phase`). An in-process plugin holding
124
+ # this context could therefore clear that list and erase a CRITICAL
125
+ # escalation out from under the audit, letting a verify-green repair
126
+ # proceed where the run owed a pause. Copying at all exists to make
127
+ # "plugins observe, cannot alter" true; shallow made it true only of the
128
+ # top level, which is not where escalations live.
129
+ self._result_json = copy.deepcopy(result_json) if result_json is not None else None
130
+ self._session_status = session_status
131
+ self._verify_reason = verify_reason
132
+ # Frozen command-result records with immutable strings. This is an
133
+ # observe-only surface: plugins cannot replace the verifier outcome or
134
+ # modify this tuple, and the engine never reads it back for a decision.
135
+ self._command_results = tuple(command_results)
136
+ # The journal correlation keys for the pass those records came from —
137
+ # `verification_stage` is also the dev-vs-repair discriminator, which
138
+ # neither `stage` (both legs emit `post_dev_verify`) nor `phase` (both
139
+ # are DEV_VERIFY) nor `attempt` (one counter, shared) can supply.
140
+ self._verification_stage = verification_stage
141
+ self._verification_sequence = verification_sequence
142
+ self._decision_action = decision_action
143
+ self._settings = dict(settings) if settings is not None else {}
144
+ # free-form, persisted across stages (engine backs it with plugin_shared)
145
+ self.shared: dict[str, Any] = shared if shared is not None else {}
146
+ # mutable whitelist (plain public attributes; engine reads them back)
147
+ self.proposed_prompt = proposed_prompt
148
+ self.proposed_env = dict(proposed_env) if proposed_env is not None else None
149
+ self.proposed_feedback = proposed_feedback
150
+ self.proposed_commit_message = proposed_commit_message
151
+ self.proposed_decision = proposed_decision
152
+ # veto collection + the plugin currently dispatching (set by the bus so
153
+ # ctx.veto() can attribute the objection without the plugin passing a name)
154
+ self._vetoes: list[Veto] = []
155
+ self._current_plugin = ""
156
+
157
+ # ---------------------------------------------------------- read-only view
158
+
159
+ @property
160
+ def stage(self) -> str:
161
+ return self._stage
162
+
163
+ @property
164
+ def run_id(self) -> str:
165
+ return self._run_id
166
+
167
+ @property
168
+ def story_key(self) -> str | None:
169
+ return self._story_key
170
+
171
+ @property
172
+ def epic(self) -> int | None:
173
+ return self._epic
174
+
175
+ @property
176
+ def phase(self) -> str | None:
177
+ return self._phase
178
+
179
+ @property
180
+ def attempt(self) -> int | None:
181
+ return self._attempt
182
+
183
+ @property
184
+ def role(self) -> str | None:
185
+ return self._role
186
+
187
+ @property
188
+ def worktree(self) -> str | None:
189
+ return self._worktree
190
+
191
+ @property
192
+ def branch(self) -> str | None:
193
+ return self._branch
194
+
195
+ @property
196
+ def repo_root(self) -> str | None:
197
+ return self._repo_root
198
+
199
+ @property
200
+ def run_dir(self) -> str | None:
201
+ return self._run_dir
202
+
203
+ @property
204
+ def agents(self) -> tuple[str, ...]:
205
+ return self._agents
206
+
207
+ @property
208
+ def result_json(self) -> dict[str, Any] | None:
209
+ return self._result_json
210
+
211
+ @property
212
+ def session_status(self) -> str | None:
213
+ return self._session_status
214
+
215
+ @property
216
+ def verify_reason(self) -> str | None:
217
+ return self._verify_reason
218
+
219
+ @property
220
+ def command_results(self) -> tuple[CommandResult, ...]:
221
+ """The verifier ``CommandResult`` records from this attempt's verify pass,
222
+ in the order the commands ran. Read-only observability for
223
+ ``post_dev_verify``; nothing here feeds an engine decision.
224
+
225
+ Empty is ambiguous ON ITS OWN and must not be read as "the commands did
226
+ not run" — read it together with :attr:`verification_stage`, which is what
227
+ separates the cases:
228
+
229
+ * ``verification_stage is None`` — no verify pass ran at all. FOUR
230
+ distinct causes reach here and an empty tuple names none of them:
231
+
232
+ 1. the session did not complete — ``session_status`` says so;
233
+ 2. the dev-artifact gate already failed the attempt — ``verify_reason``;
234
+ 3. on the repair leg, the deferral harvest short-circuited ahead of the
235
+ commands — also ``verify_reason``;
236
+ 4. the engine variant suppressed the pass for this leg —
237
+ ``StoriesEngine`` skips it on a plan-halt leg, which has no
238
+ implementation to build, so nothing on the context marks this one
239
+ apart from a run that simply configured no commands.
240
+
241
+ ``session_status`` and ``verify_reason`` separate 1–3; this tuple
242
+ separates none of them, and does not try.
243
+ * ``verification_stage`` set with ``verification_sequence is None`` — the
244
+ pass DID run and executed nothing, because ``[verify] commands`` is
245
+ empty. No journal record exists for it either.
246
+ * ``verification_stage`` set with an int ``verification_sequence`` — those
247
+ commands ran, and each has a matching journal entry (see that property).
248
+ """
249
+ return self._command_results
250
+
251
+ @property
252
+ def verification_stage(self) -> str | None:
253
+ """Which leg produced :attr:`command_results` — ``"dev"`` for the initial
254
+ dev verification, ``"fix"`` for a feedback-driven repair one, ``None``
255
+ when no verify pass ran (see :attr:`command_results`).
256
+
257
+ This is the ONLY discriminator between the two. ``stage`` and ``phase``
258
+ are literally identical across them (``post_dev_verify`` from
259
+ ``Phase.DEV_VERIFY``), and ``attempt`` is one per-story counter the
260
+ repair leg CONTINUES rather than restarts — so its value orders the two
261
+ but never names either, and a human re-arm reuses the numbers outright.
262
+ """
263
+ return self._verification_stage
264
+
265
+ @property
266
+ def verification_sequence(self) -> int | None:
267
+ """This story's 1-based ordinal for the verify pass that produced
268
+ :attr:`command_results`, or ``None`` when the pass recorded nothing.
269
+
270
+ The join key back to the run journal: the ``verify-command-result``
271
+ entries with this ``story_key`` + ``verification_stage`` +
272
+ ``verification_sequence`` are exactly these results, one per record,
273
+ ordered by their ``command_index``. Monotonic per story across a
274
+ pause/resume — the sequence is durable, unlike ``attempt``, which a human
275
+ re-arm can reuse.
276
+ """
277
+ return self._verification_sequence
278
+
279
+ @property
280
+ def decision_action(self) -> str | None:
281
+ return self._decision_action
282
+
283
+ @property
284
+ def settings(self) -> dict[str, Any]:
285
+ return self._settings
286
+
287
+ # ------------------------------------------------------------ veto surface
288
+
289
+ def veto(self, action: str, reason: str = "") -> None:
290
+ """Plugin-facing: object to this stage. Attributed to the dispatching
291
+ plugin. Multiple plugins (and multiple calls) accumulate — the engine
292
+ resolves the most-conservative one."""
293
+ self._vetoes.append(
294
+ Veto(action=action, reason=reason, plugin_id=self._current_plugin or "?")
295
+ )
296
+
297
+ def add_veto(self, veto: Veto) -> None:
298
+ """Bus-facing: record a veto synthesized from a declarative hook's exit
299
+ code or stdout (the plugin id is already filled in)."""
300
+ self._vetoes.append(veto)
301
+
302
+ @property
303
+ def vetoed(self) -> bool:
304
+ return bool(self._vetoes)
305
+
306
+ @property
307
+ def vetoes(self) -> tuple[Veto, ...]:
308
+ return tuple(self._vetoes)
309
+
310
+ def resolved_veto(self) -> Veto | None:
311
+ """The single most-conservative veto (pause > defer > skip), or None.
312
+ Ties resolve to the first raised, so a deterministic plugin order gives a
313
+ deterministic outcome."""
314
+ if not self._vetoes:
315
+ return None
316
+ return max(self._vetoes, key=lambda v: _VETO_SEVERITY[v.action])
317
+
318
+ def __repr__(self) -> str: # pragma: no cover - debug aid
319
+ return f"<HookContext {self._stage!r} story={self._story_key!r} vetoes={len(self._vetoes)}>"