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,276 @@
1
+ """Schema-driven settings: the presentation layer over policy.toml.
2
+
3
+ The settings UI used to carry a hand-written list of field descriptors. That
4
+ list now lives in ``data/settings/core.toml`` as a *schema* — widget kind,
5
+ label, help text, options, bounds — while defaults and select options are
6
+ *referenced* from the ``policy.py`` dataclasses/enum-sets rather than copied, so
7
+ ``policy.py`` stays the single source of truth for the runtime model and a sync
8
+ test guarantees the schema can never drift from it.
9
+
10
+ Two shapes mirror the old screen vocabulary:
11
+
12
+ * ``SettingSpec`` — one field (the old ``_Field``): section, key, widget kind,
13
+ options, resolved default, placeholder, numeric bounds, label, description.
14
+ ``widget_id`` is unchanged so every existing widget id (``#limits-max_review_cycles``,
15
+ ``#adapter-review-model`` …) is byte-identical.
16
+ * ``SectionSpec`` — a collapsible group: name (the TOML section key), display
17
+ label, description, its fields, and the owning plugin ("" for core).
18
+
19
+ ``load_core_schema()`` parses the bundled core schema; ``build_registry(project,
20
+ policy)`` returns it plus a ``PluginInfo`` for every *discovered* plugin — its
21
+ enable state (membership in ``[plugins] enabled``), whether enabling it grants
22
+ trust (a ``[python]`` module), and its ``[[settings]]`` fields. The screen groups
23
+ these under a dedicated Plugins area; reading manifests never imports plugin
24
+ Python. The registry is what the settings screen consumes — it never reaches
25
+ back into this module's internals.
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import tomllib
31
+ from dataclasses import dataclass, field, replace
32
+ from importlib import resources
33
+ from pathlib import Path
34
+ from typing import Any
35
+
36
+ from . import policy as policy_mod
37
+ from .plugins import load_plugins
38
+ from .plugins.model import SettingSpec as PluginSettingSpec
39
+ from .policy import STAGES
40
+
41
+ # plugin [[settings]] types -> settings-screen widget kinds. The screen knows
42
+ # switch/int/float/str/select; a plugin declares bool/int/float/str/select.
43
+ _PLUGIN_KIND = {"bool": "switch", "int": "int", "float": "float", "str": "str", "select": "select"}
44
+
45
+
46
+ @dataclass(frozen=True)
47
+ class SettingSpec:
48
+ """One settings field. Same vocabulary as the screen's former ``_Field`` so
49
+ the generic compose/collect/save logic is untouched."""
50
+
51
+ section: str
52
+ key: str
53
+ kind: str # select | int | float | str | switch | lines | args
54
+ options: tuple[str, ...] = ()
55
+ default: Any = None
56
+ placeholder: str = ""
57
+ minimum: float | None = None
58
+ maximum: float | None = None
59
+ label: str = "" # display override; falls back to key when empty
60
+ description: str = "" # muted caption shown below the field row
61
+
62
+ @property
63
+ def widget_id(self) -> str:
64
+ return f"{self.section}-{self.key}".replace(".", "-")
65
+
66
+
67
+ @dataclass(frozen=True)
68
+ class SectionSpec:
69
+ """A collapsible settings group. ``name`` is the policy.toml section key
70
+ (may be dotted, e.g. ``adapter.dev``). Core only — plugin settings are
71
+ carried by ``PluginInfo``."""
72
+
73
+ name: str
74
+ fields: tuple[SettingSpec, ...]
75
+ label: str = "" # display label; falls back to name
76
+ description: str = ""
77
+
78
+ @property
79
+ def title(self) -> str:
80
+ base = self.label or self.name
81
+ return f"{base} — {self.description}" if self.description else base
82
+
83
+
84
+ @dataclass(frozen=True)
85
+ class PluginInfo:
86
+ """One discovered plugin as the settings screen sees it: an enable toggle
87
+ (when trust-gated) plus its settings fields.
88
+
89
+ ``trust_needed`` is True for a plugin that declares an in-process ``[python]``
90
+ module — enabling it grants trust (its name enters ``[plugins] enabled``).
91
+ Data-only plugins are always active and carry no toggle. ``fields`` are this
92
+ plugin's settings under ``plugins.<name>``; they render whether or not the
93
+ plugin is enabled (the screen greys them until enabled)."""
94
+
95
+ name: str
96
+ description: str
97
+ enabled: bool
98
+ trust_needed: bool
99
+ fields: tuple[SettingSpec, ...] = ()
100
+
101
+
102
+ @dataclass(frozen=True)
103
+ class SettingsRegistry:
104
+ """Core schema plus a PluginInfo per discovered plugin — the settings
105
+ screen's source.
106
+
107
+ ``plugin_schemas`` maps each plugin that contributes settings to its declared
108
+ specs so the screen can pass them to ``policy.loads`` for typed validation of
109
+ the ``[plugins.<name>]`` tables on save (every discovered plugin, since
110
+ settings are data that may be set whether or not the plugin is enabled).
111
+ """
112
+
113
+ sections: tuple[SectionSpec, ...]
114
+ plugins: tuple[PluginInfo, ...] = ()
115
+ plugin_schemas: dict[str, tuple[PluginSettingSpec, ...]] = field(default_factory=dict)
116
+
117
+ def fields(self) -> tuple[SettingSpec, ...]:
118
+ core = tuple(f for s in self.sections for f in s.fields)
119
+ plugin = tuple(f for p in self.plugins for f in p.fields)
120
+ return core + plugin
121
+
122
+
123
+ # --------------------------------------------------------------- ref resolution
124
+
125
+
126
+ def _resolve_ref(ref: str) -> Any:
127
+ """``"ScmPolicy.merge_strategy"`` -> the dataclass default value;
128
+ ``"GATE_MODES"`` -> the module-level enum set (caller sorts it)."""
129
+ cls_name, _, attr = ref.partition(".")
130
+ target = getattr(policy_mod, cls_name)
131
+ return getattr(target, attr) if attr else target
132
+
133
+
134
+ def _resolve_field(section: str, raw: dict) -> SettingSpec:
135
+ if "options_ref" in raw:
136
+ options = tuple(sorted(_resolve_ref(raw["options_ref"])))
137
+ else:
138
+ options = tuple(str(o) for o in raw.get("options", ()))
139
+ if "default_ref" in raw:
140
+ default = _resolve_ref(raw["default_ref"])
141
+ else:
142
+ default = raw.get("default")
143
+ return SettingSpec(
144
+ section=section,
145
+ key=str(raw["key"]),
146
+ kind=str(raw["kind"]),
147
+ options=options,
148
+ default=default,
149
+ placeholder=str(raw.get("placeholder", "")),
150
+ minimum=raw.get("minimum"),
151
+ maximum=raw.get("maximum"),
152
+ label=str(raw.get("label", "")),
153
+ description=str(raw.get("description", "")),
154
+ )
155
+
156
+
157
+ def _expand_section(raw: dict) -> list[SectionSpec]:
158
+ """One core.toml ``[[section]]`` -> one SectionSpec, except a stage template
159
+ (``expand_stages``) fans out to one section per STAGES entry, substituting
160
+ ``{stage}`` in the name/label/description and reusing the same field set."""
161
+ fields_raw = raw.get("field", [])
162
+ if raw.get("expand_stages"):
163
+ sections = []
164
+ for stage in STAGES:
165
+ name = str(raw["name"]).format(stage=stage)
166
+ sections.append(
167
+ SectionSpec(
168
+ name=name,
169
+ fields=tuple(_resolve_field(name, f) for f in fields_raw),
170
+ label=str(raw.get("label", "")).format(stage=stage),
171
+ description=str(raw.get("description", "")).format(stage=stage),
172
+ )
173
+ )
174
+ return sections
175
+ name = str(raw["name"])
176
+ return [
177
+ SectionSpec(
178
+ name=name,
179
+ fields=tuple(_resolve_field(name, f) for f in fields_raw),
180
+ label=str(raw.get("label", "")),
181
+ description=str(raw.get("description", "")),
182
+ )
183
+ ]
184
+
185
+
186
+ def load_core_schema() -> tuple[SectionSpec, ...]:
187
+ """Parse the bundled core schema into ordered SectionSpecs (render order)."""
188
+ text = (
189
+ resources.files("froid_loop.data").joinpath("settings/core.toml").read_text(encoding="utf-8")
190
+ )
191
+ doc = tomllib.loads(text)
192
+ sections: list[SectionSpec] = []
193
+ for raw in doc.get("section", []):
194
+ sections.extend(_expand_section(raw))
195
+ return tuple(sections)
196
+
197
+
198
+ # ------------------------------------------------------------- plugin sections
199
+
200
+
201
+ def _plugin_fields(name: str, specs: tuple[PluginSettingSpec, ...]) -> tuple[SettingSpec, ...]:
202
+ section = f"plugins.{name}"
203
+ return tuple(
204
+ SettingSpec(
205
+ section=section,
206
+ key=s.key,
207
+ kind=_PLUGIN_KIND.get(s.type, "str"),
208
+ options=s.options,
209
+ default=s.default,
210
+ minimum=s.min,
211
+ maximum=s.max,
212
+ label=s.label,
213
+ description=s.help,
214
+ )
215
+ for s in specs
216
+ )
217
+
218
+
219
+ def _inject_mux_backends(sections: tuple[SectionSpec, ...], policy: Any) -> tuple[SectionSpec, ...]:
220
+ """Populate the ``mux.backend`` select with the backends registered on THIS host.
221
+
222
+ Registered backends are a runtime fact (not a policy enum set), so the option
223
+ list is built per screen-open from ``detect_multiplexers()`` rather than baked
224
+ into ``core.toml``. Any value already present in policy is unioned in, so a
225
+ backend forced via ``froid-loop mux set --force <name>`` on a host where it is not
226
+ registered is never silently dropped when the settings screen saves (a blank
227
+ Select would collect to ``None`` = delete the key). The empty string (auto-select)
228
+ stays the implicit blank choice and is not listed."""
229
+ from .adapters.multiplexer import detect_multiplexers
230
+
231
+ current = getattr(getattr(policy, "mux", None), "backend", "") or ""
232
+ names = [info.name for info in detect_multiplexers()]
233
+ options = tuple(name for name in dict.fromkeys([*names, current]) if name)
234
+ out: list[SectionSpec] = []
235
+ for section in sections:
236
+ if section.name != "mux":
237
+ out.append(section)
238
+ continue
239
+ fields = tuple(
240
+ replace(f, options=options) if f.key == "backend" else f for f in section.fields
241
+ )
242
+ out.append(replace(section, fields=fields))
243
+ return tuple(out)
244
+
245
+
246
+ def build_registry(project: Path | None = None, policy: Any = None) -> SettingsRegistry:
247
+ """Core schema, plus a PluginInfo for every *discovered* plugin.
248
+
249
+ Every plugin found by discovery is surfaced so the screen can offer an enable
250
+ toggle (membership in ``[plugins] enabled``) and group its settings. Reading
251
+ manifests never imports/executes plugin Python — that stays trust-gated for
252
+ hooks. A plugin's settings are data that render whether or not it is enabled
253
+ (the screen greys them until enabled), so ``plugin_schemas`` covers every
254
+ plugin that contributes settings, making each ``[plugins.<name>]`` table
255
+ typed on save regardless of toggle state."""
256
+ sections = _inject_mux_backends(load_core_schema(), policy)
257
+ enabled = set(getattr(getattr(policy, "plugins", None), "enabled", ()) or ())
258
+ manifests = load_plugins(project)
259
+ plugins: list[PluginInfo] = []
260
+ plugin_schemas: dict[str, tuple[PluginSettingSpec, ...]] = {}
261
+ for name in sorted(manifests):
262
+ manifest = manifests[name]
263
+ plugins.append(
264
+ PluginInfo(
265
+ name=name,
266
+ description=manifest.description,
267
+ enabled=name in enabled,
268
+ trust_needed=manifest.python is not None,
269
+ fields=_plugin_fields(name, manifest.settings),
270
+ )
271
+ )
272
+ if manifest.settings:
273
+ plugin_schemas[name] = manifest.settings
274
+ return SettingsRegistry(
275
+ sections=sections, plugins=tuple(plugins), plugin_schemas=plugin_schemas
276
+ )
froid_loop/signals.py ADDED
@@ -0,0 +1,160 @@
1
+ """Watch the per-run events directory for hook-written event files.
2
+
3
+ The hook script (data/froid_loop_hook.py) and its importable twin (:mod:`events`,
4
+ behind ``froid-loop relay``) write one JSON file per event, atomically (tmp +
5
+ rename), named "<ts_ns>-<task_id>-<event>.json". Plain polling of a near-empty
6
+ directory is cheap and crash-safe; no inotify.
7
+
8
+ Two directories, not one (#494). The channel now lives out of the project tree,
9
+ under the user-scoped state root (``runs.events_dir_for``), because a branch
10
+ switch, a worktree mount or a rollback must not be able to take a live run's
11
+ control plane away. The *legacy* in-tree ``<run_dir>/events`` stays polled as
12
+ well, and that is the whole version-skew guard: the relay a target project has
13
+ installed is a COPY taken at init time, so an upgraded orchestrator routinely
14
+ drives sessions whose hook only knows the old location. Without the second poll
15
+ that pairing loses every Stop event and every session stalls to
16
+ ``session_timeout_min`` — the loudest possible regression, delivered silently.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import json
22
+ import time
23
+ from dataclasses import dataclass
24
+ from pathlib import Path
25
+ from typing import Callable
26
+
27
+
28
+ @dataclass(frozen=True)
29
+ class HookEvent:
30
+ ts: int
31
+ event: str # Stop | SessionStart | SessionEnd | PreCompact
32
+ task_id: str
33
+ session_id: str | None
34
+ transcript_path: str | None
35
+ path: Path
36
+
37
+
38
+ class SignalWatcher:
39
+ """Poll one or two event directories for a run's hook events.
40
+
41
+ ``events_dir`` is the primary — the out-of-tree channel this orchestrator
42
+ directs its sessions to via ``FROID_LOOP_EVENTS_DIR``, and the only one
43
+ created here. ``legacy_dir`` is the pre-#494 in-tree ``<run_dir>/events``,
44
+ polled when given so a project carrying an older installed relay still
45
+ completes its sessions (see the module docstring). It is deliberately NOT
46
+ created: an orchestrator that recreated the in-tree directory would undo the
47
+ move for the operator's `git status` while gaining nothing — a legacy relay
48
+ that writes there makes the directory itself.
49
+
50
+ The single-positional-argument form is unchanged, and stays the shape the
51
+ probe (``probe.py``, watching its own capture dir) and the unit tests use.
52
+ """
53
+
54
+ def __init__(self, events_dir: Path, legacy_dir: Path | None = None):
55
+ self.events_dir = events_dir
56
+ self.legacy_dir = legacy_dir
57
+ # Keyed by (directory, filename), not by filename: one name identifies an
58
+ # event only WITHIN a directory, and the two dirs are written by two
59
+ # independent relays. Keying on the name alone would let a file consumed
60
+ # from one dir mask a different event of the same name in the other, and a
61
+ # masked event here is a lost Stop — the run then waits out
62
+ # session_timeout_min with its completion signal sitting on disk.
63
+ self._consumed: set[tuple[str, str]] = set()
64
+ self._pending: list[HookEvent] = [] # polled but not yet delivered via wait_for
65
+ events_dir.mkdir(parents=True, exist_ok=True)
66
+
67
+ def _dirs(self) -> list[Path]:
68
+ """Primary first, then the legacy dir when there is a distinct one. The
69
+ equality check keeps a caller that passes the same path twice from
70
+ double-scanning; it cannot produce duplicate events either way (the
71
+ ``_consumed`` key would repeat), but the second scan would be pure waste."""
72
+ if self.legacy_dir is None or self.legacy_dir == self.events_dir:
73
+ return [self.events_dir]
74
+ return [self.events_dir, self.legacy_dir]
75
+
76
+ def poll(self) -> list[HookEvent]:
77
+ """Return new, well-formed events since the last poll, oldest first.
78
+
79
+ Ordering is by the parsed ``ts`` across BOTH directories, so which relay
80
+ wrote an event never affects where it lands in the sequence.
81
+
82
+ A missing *legacy* directory is the normal case (nothing installed writes
83
+ there any more) and is skipped silently. A missing *primary* still raises,
84
+ as it always has: this watcher created it, so its absence means something
85
+ removed the live control plane out from under the run.
86
+ """
87
+ events: list[HookEvent] = []
88
+ dirs = self._dirs()
89
+ for directory in dirs:
90
+ try:
91
+ entries = list(directory.iterdir())
92
+ except OSError:
93
+ if directory == self.events_dir:
94
+ raise
95
+ continue # legacy dir absent — the ordinary case
96
+ for entry in entries:
97
+ key = (str(directory), entry.name)
98
+ if key in self._consumed or entry.suffix != ".json":
99
+ continue
100
+ self._consumed.add(key)
101
+ try:
102
+ data = json.loads(entry.read_text(encoding="utf-8"))
103
+ except (json.JSONDecodeError, OSError):
104
+ continue
105
+ if not isinstance(data, dict) or "event" not in data or "task_id" not in data:
106
+ continue
107
+ events.append(
108
+ HookEvent(
109
+ ts=int(data.get("ts", 0)),
110
+ event=str(data["event"]),
111
+ task_id=str(data["task_id"]),
112
+ session_id=data.get("session_id"),
113
+ transcript_path=data.get("transcript_path"),
114
+ path=entry,
115
+ )
116
+ )
117
+ events.sort(key=lambda e: e.ts)
118
+ return events
119
+
120
+ def wait_for(
121
+ self,
122
+ task_id: str,
123
+ kinds: set[str],
124
+ timeout_s: float,
125
+ poll_interval: float = 1.0,
126
+ clock: Callable[[], float] = time.monotonic,
127
+ sleep: Callable[[float], None] = time.sleep,
128
+ since_ns: int = 0,
129
+ ) -> HookEvent | None:
130
+ """Block until an event for task_id with kind in `kinds` arrives, or timeout.
131
+
132
+ Events polled but not matched stay buffered for later wait_for calls —
133
+ several events often land in one poll (e.g. SessionStart + Stop) and
134
+ none may be lost.
135
+
136
+ Events older than `since_ns` (wall-clock ns, the session's launch time)
137
+ are dropped: a resumed run reuses task_ids, and a fresh watcher re-sees the
138
+ events directory from scratch, so a prior cycle's Stop event would
139
+ otherwise replay instantly and the old result.json be read as a bogus
140
+ completion. Sessions run sequentially, so since_ns only advances; anything
141
+ below the current floor is genuinely stale and safe to discard.
142
+
143
+ A re-ARMED run no longer reuses them — `runs.rearm_escalation` bumps
144
+ `StoryTask.generation`, which `engine._session_task_id` folds into the id
145
+ (#705). That removes one source of collision; it does not make this floor
146
+ redundant, because a plain resume re-mints the SAME id by design (that is
147
+ how crash replay finds its record) and is the case this guard was written
148
+ for.
149
+ """
150
+ deadline = clock() + timeout_s
151
+ while True:
152
+ self._pending.extend(self.poll())
153
+ if since_ns:
154
+ self._pending = [e for e in self._pending if e.ts >= since_ns]
155
+ for i, event in enumerate(self._pending):
156
+ if event.task_id == task_id and event.event in kinds:
157
+ return self._pending.pop(i)
158
+ if clock() >= deadline:
159
+ return None
160
+ sleep(poll_interval)