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
froid_loop/resolve.py ADDED
@@ -0,0 +1,283 @@
1
+ """Interactive escalation resolution.
2
+
3
+ When a run pauses on a CRITICAL escalation the agent that raised it is already
4
+ gone (its tmux window was killed on completion), so there is nothing to talk
5
+ to. This module instead launches a *fresh* interactive agent — the
6
+ `froid-loop-resolve` skill — attached to the caller's terminal, seeded with the
7
+ escalation detail and the frozen spec. The human and the agent disambiguate the
8
+ spec; the agent writes a `resolution.json` marker. The caller (cli.cmd_resolve)
9
+ then re-arms the story (runs.rearm_escalation) and resumes the run.
10
+
11
+ The orchestrator never parses the conversation: the durable output is the
12
+ edited frozen spec on disk plus the resolution marker.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import json
18
+ import os
19
+ import subprocess
20
+ from pathlib import Path
21
+ from typing import Any
22
+
23
+ from .adapters.base import SessionSpec
24
+ from .model import RunState
25
+ from .platform_util import safe_segment
26
+ from .runs import (
27
+ redrive_base_ref,
28
+ spec_reaches_the_redrive,
29
+ task_spec_path,
30
+ task_stories_root,
31
+ validate_restore_latch,
32
+ )
33
+
34
+ RESOLVE_DIR = "resolve"
35
+
36
+
37
+ def _story_dir(run_dir: Path, story_key: str) -> Path:
38
+ return run_dir / RESOLVE_DIR / safe_segment(story_key)
39
+
40
+
41
+ def context_path(run_dir: Path, story_key: str) -> Path:
42
+ return _story_dir(run_dir, story_key) / "context.json"
43
+
44
+
45
+ def resolution_path(run_dir: Path, story_key: str) -> Path:
46
+ return _story_dir(run_dir, story_key) / "resolution.json"
47
+
48
+
49
+ class ResolutionError(Exception):
50
+ """resolution.json exists but cannot be used (unreadable / not a JSON object)."""
51
+
52
+
53
+ def read_resolution(run_dir: Path, story_key: str) -> dict[str, Any] | None:
54
+ """Parse the resolve agent's ``resolution.json`` marker. Returns None ONLY
55
+ when the marker is absent; a present-but-unusable marker (unreadable, bad
56
+ JSON, non-object top level) raises ResolutionError instead. The file is the
57
+ agent's recorded decision — possibly including a ``restore_patch`` (the
58
+ intent-gap patch-restore path, Froid Plane #2564) — and a re-arm consumes
59
+ the escalation, so collapsing corruption into "nothing recorded" would
60
+ silently downgrade a confirmed restore to an unrecoverable from-scratch
61
+ re-drive. The caller validates the ``restore_patch`` path itself before
62
+ acting on it."""
63
+ path = resolution_path(run_dir, story_key)
64
+ if not path.is_file():
65
+ return None
66
+ try:
67
+ doc = json.loads(path.read_text(encoding="utf-8"))
68
+ except (OSError, UnicodeDecodeError, json.JSONDecodeError) as e:
69
+ # UnicodeDecodeError is a ValueError, not an OSError — a non-UTF-8 marker
70
+ # must raise the clean ResolutionError, not crash (same class as the
71
+ # read_frontmatter / stories read-path hardening).
72
+ raise ResolutionError(f"resolution marker {path} is unreadable ({e})") from e
73
+ if not isinstance(doc, dict):
74
+ raise ResolutionError(f"resolution marker {path} is not a JSON object")
75
+ return doc
76
+
77
+
78
+ def _gather_escalations(run_dir: Path, state: RunState, story_key: str) -> list[dict[str, Any]]:
79
+ """The CRITICAL escalations recorded by this story's sessions, newest first.
80
+
81
+ Reads each session's tasks/<task_id>/result.json (and escalation.json) — the
82
+ same files the engine inspected when it decided to pause."""
83
+ task = state.tasks.get(story_key)
84
+ found: list[dict[str, Any]] = []
85
+ if task is None:
86
+ return found
87
+ for session in reversed(task.sessions):
88
+ task_dir = run_dir / "tasks" / session.task_id
89
+ for fname in ("result.json", "escalation.json"):
90
+ fpath = task_dir / fname
91
+ if not fpath.is_file():
92
+ continue
93
+ try:
94
+ doc = json.loads(fpath.read_text(encoding="utf-8"))
95
+ except (OSError, json.JSONDecodeError):
96
+ continue
97
+ for esc in doc.get("escalations", []) if isinstance(doc, dict) else []:
98
+ if isinstance(esc, dict) and str(esc.get("severity", "")).upper() == "CRITICAL":
99
+ found.append(esc)
100
+ return found
101
+
102
+
103
+ def build_context(state: RunState, run_dir: Path, story_key: str, *, isolation: str) -> Path:
104
+ """Write resolve/<story_key>/context.json for the resolve skill to read.
105
+
106
+ `isolation` is the LIVE policy's `scm.isolation`, and it is required rather than
107
+ defaulted for the reason this surface exists at all: three of the fields below —
108
+ `restore_supported`, `spec_reaches_the_redrive`, `redrive_base_ref` — are claims
109
+ about a re-drive that has NOT happened yet, and run state cannot answer them. The
110
+ mode is re-read at every resume and a mid-run change is journalled, never refused,
111
+ so the recorded `task.worktree_path` says only how the escalated attempt RAN. A
112
+ defaulted mode would hand the agent an in-place answer for a run that mounts (or the
113
+ reverse) with nothing to signal it — the defect being fixed, re-introduced at the
114
+ seam that reports it."""
115
+ task = state.tasks.get(story_key)
116
+ isolated_redrive = isolation == "worktree"
117
+ # Patch-restore availability (#2564): the shared `validate_restore_latch`
118
+ # verdict, not a local copy of one leg. Any of them — worktree isolation (the
119
+ # re-drive discards and re-mounts the unit's worktree), a spec-less escalation,
120
+ # a pre-planning sentinel wedge — means the orchestrator would reject a
121
+ # `restore_patch` after the session; told to the agent up front so it never
122
+ # negotiates a restore it can't honor.
123
+ restore_supported = task is not None and (
124
+ validate_restore_latch(state, task, story_key, worktree_isolation=isolated_redrive) is None
125
+ )
126
+ # Which tree holds this run's STORY MANIFEST — the workspace root, answered by
127
+ # `task_stories_root` rather than by `task_spec_root`. The latter answers a
128
+ # write-confinement question about `spec_file` and falls back to the project for an
129
+ # out-of-mount spec; borrowing it here pointed the sentinel and the stories block at
130
+ # the main checkout while `stories_engine._stories_folder` was still the mount, so
131
+ # one `context.json` could name two trees.
132
+ stories_root = task_stories_root(task, state)
133
+ context = {
134
+ "story_key": story_key,
135
+ "run_id": state.run_id,
136
+ # Absolute, matching the shape `froid-loop-resolve/SKILL.md` documents: an
137
+ # isolated unit's `spec_file` is persisted RELATIVE to the mounted worktree
138
+ # (`model.StoryTask._serialized_worktree_path`) and the agent session runs
139
+ # from the project root, where the main checkout carries the same
140
+ # `_froid-output/specs/...` layout — the raw value would name the wrong
141
+ # tree's copy. `task_spec_path` is the same re-anchor `rearm_escalation`
142
+ # writes through, so the agent edits the file the re-arm will flip.
143
+ # as_posix() for the same reason `resolution_path` below uses it — the
144
+ # context contract is one string on every OS — and because the value this
145
+ # replaces was ALREADY posix under isolation: `_serialized_worktree_path`
146
+ # persists the relative form with `.as_posix()`. It also normalizes the
147
+ # NON-isolated absolute case, which was previously emitted verbatim: on Windows
148
+ # that changes `C:\\...\\spec.md` to `C:/.../spec.md`. Deliberate — one
149
+ # spelling for every reader — and consumed by an agent, which accepts '/'.
150
+ "spec_file": (task_spec_path(task, state).as_posix() if task and task.spec_file else None),
151
+ "baseline_commit": task.baseline_commit if task else None,
152
+ "paused_reason": state.paused_reason,
153
+ "escalations": _gather_escalations(run_dir, state, story_key),
154
+ # as_posix so the context contract is the same string on every OS (the
155
+ # path is consumed by the agent, and Python/tools accept '/' on Windows).
156
+ "resolution_path": resolution_path(run_dir, story_key).as_posix(),
157
+ "restore_supported": restore_supported,
158
+ # Whether an edit to `spec_file` survives to the re-drive that reads it. Under
159
+ # isolation the mount is discarded by `engine._finish_inflight`, so the agent
160
+ # can otherwise spend a whole session editing a file with no future and see
161
+ # every write succeed. `rearm_escalation` already journals
162
+ # `rearm-spec-write-unreachable` on this same verdict; naming it here is what
163
+ # lets the session act on it instead of learning it afterwards.
164
+ # Guarded on `task.spec_file` exactly as `spec_file` above is: a verdict about
165
+ # whether an edit SURVIVES is meaningless beside a `"spec_file": null`, and
166
+ # emitting one invited the session to act on a reachability answer for a file
167
+ # the same document says does not exist. Both fields are one claim.
168
+ "spec_reaches_the_redrive": (
169
+ spec_reaches_the_redrive(task, state, isolated_redrive=isolated_redrive)
170
+ if task and task.spec_file
171
+ else None
172
+ ),
173
+ # WHERE a correction has to land to be read. Emitted beside the verdict
174
+ # because on its own `spec_reaches_the_redrive: false` states a problem with
175
+ # no remedy: the session is told the edit is doomed, and the obvious repair
176
+ # (commit it) is wrong in two different ways for an isolated unit. Committing
177
+ # from the main checkout cannot include a file that lives in the linked unit
178
+ # worktree, and committing on the unit's own branch does not put it on the ref
179
+ # the replacement worktree is cut from. That ref is this one — the run's
180
+ # PINNED `target_branch` when the re-drive will MOUNT, `HEAD` otherwise — and
181
+ # it is the same value `rearm_escalation`'s unreachable-write record names, so
182
+ # the session and the orchestrator quote one answer.
183
+ #
184
+ # Keyed on the LIVE isolation mode, not on the recorded mount. This file is
185
+ # written by a SEPARATE process, before the resume runs, so it is the one
186
+ # consumer no amount of resume-time bookkeeping on `task.worktree_path` could
187
+ # reach: reading the mount here sent the session to commit on the pinned branch
188
+ # for a run whose policy had since flipped to `none`, where the re-drive reads
189
+ # `HEAD` in the main checkout and never looks — and answered `HEAD` for the
190
+ # mirror flip, where it mounts and never reads a working tree at all.
191
+ "redrive_base_ref": (
192
+ redrive_base_ref(state, isolated_redrive=isolated_redrive) if task else None
193
+ ),
194
+ }
195
+ # Stories mode: hand the resolver the manifest intent (the story entry) and a
196
+ # sentinel indicator, so it sees WHAT the story is meant to do and WHETHER the
197
+ # frozen spec even exists yet (a sentinel has no plan to edit — resolve the
198
+ # underlying ambiguity instead). Sprint mode leaves the context unchanged.
199
+ if state.source == "stories":
200
+ stories_ctx = _stories_context(state, story_key, stories_root)
201
+ if stories_ctx:
202
+ context["stories"] = stories_ctx
203
+ path = context_path(run_dir, story_key)
204
+ path.parent.mkdir(parents=True, exist_ok=True)
205
+ path.write_text(json.dumps(context, indent=2), encoding="utf-8")
206
+ return path
207
+
208
+
209
+ def _stories_context(state: RunState, story_key: str, root: Path) -> dict[str, Any]:
210
+ """The stories-mode extension of the resolve context: the spec folder, the
211
+ manifest entry for the story (title/description/checkpoint flags/invoke_dev_with),
212
+ and — when the escalated spec is a fixed-slug pre-planning-halt sentinel — a
213
+ sentinel indicator with its kind and recorded blocking condition. Best-effort:
214
+ an unreadable manifest just yields the folder (resolve still runs)."""
215
+ from . import stories
216
+
217
+ # `root`, not `Path(state.project)`: the caller resolved it with
218
+ # `task_stories_root`, so this block reads the manifest and sentinel out of the tree
219
+ # the RUN owns. One `context.json` that names two trees is worse than one that names
220
+ # the wrong one — `sentinel.path` and `blocking_condition` would otherwise describe a
221
+ # file the re-arm will never touch, or vanish entirely because the main checkout has
222
+ # no sentinel while the mount does.
223
+ folder = stories.resolve_spec_folder(root, state.spec_folder)
224
+ ctx: dict[str, Any] = {"spec_folder": state.spec_folder}
225
+ try:
226
+ entry = stories.load_stories(folder).get(story_key)
227
+ except (stories.StoriesError, OSError, UnicodeDecodeError):
228
+ entry = None
229
+ if entry is not None:
230
+ ctx["story"] = {
231
+ "id": entry.id,
232
+ "title": entry.title,
233
+ "description": entry.description,
234
+ "spec_checkpoint": entry.spec_checkpoint,
235
+ "done_checkpoint": entry.done_checkpoint,
236
+ "invoke_dev_with": entry.invoke_dev_with,
237
+ }
238
+ try:
239
+ st = stories.resolve_story_spec(folder, story_key)
240
+ except (OSError, UnicodeDecodeError):
241
+ st = None
242
+ if st is not None and st.kind == stories.KIND_SENTINEL and st.path is not None:
243
+ try:
244
+ condition = stories.recorded_blocking_condition(st.path.read_text(encoding="utf-8"))
245
+ except (OSError, UnicodeDecodeError):
246
+ condition = ""
247
+ ctx["sentinel"] = {
248
+ "kind": st.sentinel_kind,
249
+ "path": st.path.as_posix(),
250
+ "blocking_condition": condition,
251
+ }
252
+ return ctx
253
+
254
+
255
+ def run_session(adapter, project: Path, run_dir: Path, story_key: str, *, model: str = "") -> bool:
256
+ """Launch the interactive resolve agent attached to the caller's terminal.
257
+
258
+ Blocks until the agent session exits. Returns whether the agent produced a
259
+ resolution marker. The context file must already be written (build_context).
260
+ """
261
+ spec = SessionSpec(
262
+ task_id=f"{safe_segment(story_key)}-resolve-1",
263
+ role="dev",
264
+ prompt=f"/froid-loop-resolve {story_key}",
265
+ cwd=project,
266
+ env={
267
+ # deliberately NOT FROID_LOOP_MODE: this session is interactive, a
268
+ # human is present, the skill must be allowed to ask.
269
+ "FROID_LOOP_RUN_DIR": str(run_dir),
270
+ "FROID_LOOP_STORY_KEY": story_key,
271
+ "FROID_LOOP_RESOLVE_CONTEXT": str(context_path(run_dir, story_key)),
272
+ },
273
+ model=model,
274
+ )
275
+ # Drop any marker from a previous resolve of this story: otherwise the agent
276
+ # sees it and reports "already resolved", and a session that records nothing
277
+ # would still look like it produced a resolution.
278
+ marker = resolution_path(run_dir, story_key)
279
+ marker.unlink(missing_ok=True)
280
+ argv = adapter.interactive_argv(spec)
281
+ env = {**os.environ, **adapter.interactive_env(spec)}
282
+ subprocess.run(argv, cwd=str(project), env=env) # attached, inherited stdio
283
+ return marker.is_file()