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,320 @@
1
+ """Where code+git work happens, decoupled from where run state lives.
2
+
3
+ A Workspace pairs the directory sessions run in (and git operates on) with the
4
+ artifact paths rebased onto it. Run state (run_dir, journal, state.json) always
5
+ lives in the main repo and is passed separately — it never moves.
6
+
7
+ - isolation = none → Workspace.default(paths): root = paths.repo_root, behavior
8
+ identical to operating directly on the project.
9
+ - isolation = worktree → per unit: a git worktree mounted under the run dir
10
+ (.froid-loop/runs/<run_id>/worktrees/, which `froid-loop init` gitignores, so it
11
+ stays invisible to the main checkout's `git status`), with paths rebased onto
12
+ it. open_unit_workspace / close_unit_workspace manage the branch + worktree
13
+ lifecycle; the engine merges the unit branch back into the target branch from
14
+ the main repo between units.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import shutil
20
+ from collections.abc import Callable
21
+ from dataclasses import dataclass
22
+ from pathlib import Path
23
+
24
+ from . import verify
25
+ from .froidconfig import ProjectPaths
26
+ from .platform_util import safe_ref_segment, safe_segment
27
+
28
+ # Per-unit worktrees live under the run dir (.froid-loop/runs/<run_id>/worktrees/),
29
+ # which `froid-loop init` already gitignores — so unit checkouts never show up as
30
+ # untracked files in the main checkout. Crucially they must NOT live under .git/:
31
+ # a cwd inside .git/ is treated as git-internal by the coding CLIs (Claude Code),
32
+ # which then refuse to load the project's froid-loop-* skills — breaking every
33
+ # worktree session (`Unknown command: /froid-build-auto`).
34
+ WORKTREE_DIRNAME = "worktrees"
35
+
36
+
37
+ def unit_worktrees_dir(run_dir: Path) -> Path:
38
+ """The parent dir holding this run's per-unit worktrees."""
39
+ return run_dir / WORKTREE_DIRNAME
40
+
41
+
42
+ def _rmtree_confined(wt: Path, run_dir: Path) -> bool:
43
+ """rmtree `wt` only when it resolves to a strict descendant of this run's
44
+ worktrees dir; returns whether deletion was attempted. The teardown fallbacks
45
+ reach for rmtree with paths that can arrive from persisted task state
46
+ (`task.worktree_path` via discard_worktree and `_reopen_unit`), and rmtree —
47
+ unlike `git worktree remove` — performs no validation of its own, so a
48
+ corrupt or hand-edited state entry must not be able to point it at the repo
49
+ root or anywhere else outside the run's scaffolding (same doctrine as
50
+ runs.reconcile_orphan_worktrees / resolve_run_dir)."""
51
+ try:
52
+ root = unit_worktrees_dir(run_dir).resolve()
53
+ target = wt.resolve()
54
+ target.relative_to(root)
55
+ except (ValueError, OSError):
56
+ return False
57
+ if target == root:
58
+ return False
59
+ shutil.rmtree(target, ignore_errors=True)
60
+ return True
61
+
62
+
63
+ @dataclass(frozen=True)
64
+ class Workspace:
65
+ root: Path # where sessions run (cwd) and git operates
66
+ paths: ProjectPaths # artifact paths rebased onto `root`
67
+
68
+ @classmethod
69
+ def default(cls, paths: ProjectPaths) -> Workspace:
70
+ """The zero-config workspace: work happens in the repo root in place."""
71
+ return cls(root=paths.repo_root, paths=paths)
72
+
73
+
74
+ @dataclass(frozen=True)
75
+ class UnitWorkspace:
76
+ """A per-unit worktree workspace plus the bookkeeping needed to merge it
77
+ back and tear it down from the main repo."""
78
+
79
+ workspace: Workspace # rebased onto the worktree dir
80
+ repo_root: Path # the main repo (where merges + worktree removal happen)
81
+ branch: str # the unit branch checked out in the worktree
82
+ path: Path # the worktree dir
83
+ baseline: str # commit the worktree was cut from (for failed-diff capture)
84
+
85
+
86
+ def unit_branch_name(run_id: str, unit_key: str, branch_per: str) -> str:
87
+ """branch_per=run shares one branch across the whole run; branch_per=story
88
+ gives each unit its own branch.
89
+
90
+ Both segments are ref-sanitized: `--run-id` is user-suppliable and a unit key is
91
+ a sprint-board / ledger id, so either can carry ref-illegal sequences (`:`, `..`,
92
+ `@{`, a trailing `.lock`) that git rejects at branch-creation time. Clean ids —
93
+ every auto-generated run id and every conventional story key — pass through
94
+ byte-identical. This is the single source of the name: `open_unit_workspace` is
95
+ the sole caller and stores the result on `task.branch`, which every consumer
96
+ (`_merge_local`, `discard_worktree`, `close_unit_workspace`) reuses.
97
+ """
98
+ if branch_per == "run":
99
+ return f"froid-loop/{safe_ref_segment(run_id)}"
100
+ return f"froid-loop/{safe_ref_segment(run_id)}/{safe_ref_segment(unit_key)}"
101
+
102
+
103
+ def open_unit_workspace(
104
+ repo_root: Path,
105
+ paths: ProjectPaths,
106
+ run_id: str,
107
+ unit_key: str,
108
+ base: str,
109
+ branch_per: str,
110
+ run_dir: Path,
111
+ ) -> UnitWorkspace:
112
+ """Mount a fresh worktree for `unit_key` and return its rebased workspace.
113
+
114
+ The worktree is mounted under the run dir (see unit_worktrees_dir), not under
115
+ .git/. The unit branch is cut from `base` (the target branch's HEAD). When the
116
+ branch already exists (branch_per=run re-mounting the shared run branch
117
+ across serial units) it is re-checked-out from its own HEAD instead, so it
118
+ keeps the commits earlier units already landed on it.
119
+ """
120
+ branch = unit_branch_name(run_id, unit_key, branch_per)
121
+ unresolved_wt = unit_worktrees_dir(run_dir) / safe_segment(unit_key)
122
+ try:
123
+ wt = unresolved_wt.resolve()
124
+ except (OSError, RuntimeError) as e:
125
+ raise verify.GitError(
126
+ f"cannot resolve worktree mount path for {unit_key} ({unresolved_wt}): {e}"
127
+ ) from e
128
+ wt.parent.mkdir(parents=True, exist_ok=True)
129
+ # Reclaim whatever still occupies this unit's mount point before adding.
130
+ # `wt` and `branch` are both DETERMINISTIC in (run_id, unit_key, run_dir), so a
131
+ # re-mount targets the exact path a previous mount used — and `worktree_add`
132
+ # refuses a target that exists or a branch checked out elsewhere, which makes a
133
+ # leftover registration a hard `GitError` rather than a recoverable state.
134
+ # `engine._finish_inflight` reaches that shape by design: when live policy leaves
135
+ # isolation it releases the mount's state and clears the task's claim but
136
+ # deliberately LEAVES the directory standing (the journal names it), so a later
137
+ # flip back to `worktree` re-derives this same path and used to be unrecoverable
138
+ # through the normal run flow. Reclaiming here rather than deleting at the flip
139
+ # keeps that preservation intact for the in-place run and spends the orphan only
140
+ # when a mount actually needs its path.
141
+ #
142
+ # The BRANCH is deliberately not passed: `discard_worktree` would force-delete it,
143
+ # and under `branch_per=run` this name is the SHARED run branch carrying commits
144
+ # earlier units already landed. Dropping only the worktree frees the checkout that
145
+ # blocks `worktree_add` while leaving those commits reachable, so the
146
+ # `branch_exists` fork below still re-mounts the branch from its own HEAD.
147
+ discard_worktree(repo_root, str(wt), "", run_dir=run_dir)
148
+ if verify.branch_exists(repo_root, branch):
149
+ verify.worktree_add(repo_root, wt, branch, create=False)
150
+ else:
151
+ verify.worktree_add(repo_root, wt, branch, base=base, create=True)
152
+ baseline = verify.rev_parse_head(wt)
153
+ return UnitWorkspace(
154
+ workspace=Workspace(root=wt, paths=paths.rebased(wt)),
155
+ repo_root=repo_root,
156
+ branch=branch,
157
+ path=wt,
158
+ baseline=baseline,
159
+ )
160
+
161
+
162
+ def close_unit_workspace(
163
+ unit: UnitWorkspace,
164
+ *,
165
+ success: bool,
166
+ keep_failed: bool,
167
+ run_dir: Path,
168
+ unit_key: str,
169
+ delete_branch: bool = True,
170
+ detach_kept: bool = False,
171
+ diff_max_file_bytes: int | None = None,
172
+ on_teardown_degraded: Callable[[str], None] | None = None,
173
+ ) -> Path | None:
174
+ """Tear down (or preserve) a unit's worktree.
175
+
176
+ On failure the unit's full diff against its baseline is written to
177
+ `run_dir/failed/<unit_key>/changes.patch` for forensics; when keep_failed is
178
+ set the worktree + branch are left mounted for inspection and nothing else
179
+ happens. On success (or failure without keep_failed) the worktree is removed
180
+ and, if delete_branch, the branch deleted. Returns the patch path it wrote,
181
+ or None.
182
+
183
+ Invariant: the teardown tail is post-merge/post-capture housekeeping — the
184
+ unit's content is already safe (merged on success, patch-captured on failure)
185
+ before it runs, so no git teardown failure escapes it. Every `GitError` from
186
+ the worktree removal or branch deletion degrades to a call of
187
+ on_teardown_degraded (given the failure message) instead of crashing the run;
188
+ a clean or force-retried removal is silent (see the teardown tail below). A
189
+ failed diff *capture* breaks the tail's premise instead — the worktree would
190
+ then hold the only copy of the unit's changes — so it is reported the same
191
+ way but preserves the worktree + branch rather than tearing them down. The
192
+ callback itself is the caller's (the engine's `journal.append`, whose OSError
193
+ is engine-wide journal semantics, deliberately unguarded here).
194
+
195
+ detach_kept (branch_per=run only): when keep_failed preserves the worktree,
196
+ detach its HEAD so the shared run branch it holds is freed for the next unit
197
+ to mount — otherwise every later unit's `git worktree add` collides on the
198
+ already-checked-out branch. Best effort; see the keep_failed branch below.
199
+
200
+ diff_max_file_bytes caps the per-untracked-file size in that forensic patch
201
+ (None = no cap); see verify.capture_diff.
202
+ """
203
+ patch: Path | None = None
204
+ if not success:
205
+ capture_err: verify.GitError | None = None
206
+ try:
207
+ diff = (
208
+ verify.capture_diff(unit.path, unit.baseline, max_file_bytes=diff_max_file_bytes)
209
+ if unit.baseline
210
+ else ""
211
+ )
212
+ except verify.GitError as e:
213
+ capture_err = e
214
+ diff = ""
215
+ if diff:
216
+ patch = run_dir / "failed" / safe_segment(unit_key) / "changes.patch"
217
+ patch.parent.mkdir(parents=True, exist_ok=True)
218
+ patch.write_text(diff, encoding="utf-8")
219
+ if capture_err is not None and on_teardown_degraded is not None:
220
+ # the forensic patch is the only copy of a dropped unit's changes; a
221
+ # failed capture means the teardown below would destroy them, so the
222
+ # unit is preserved as if keep_failed (the `or` on the branch below).
223
+ on_teardown_degraded(
224
+ f"diff capture failed for {unit.path}: {capture_err}; "
225
+ "worktree and branch preserved (uncaptured changes)"
226
+ )
227
+ if keep_failed or capture_err is not None:
228
+ if detach_kept:
229
+ # branch_per=run shares one branch across the run; a kept worktree
230
+ # left checked out on it blocks every later unit's `git worktree
231
+ # add` (git refuses a branch checked out elsewhere). Detach HEAD to
232
+ # free the shared branch name while preserving the working tree,
233
+ # uncommitted changes, and the branch ref (still at the kept commit)
234
+ # for inspection. Best effort: on failure the later unit still
235
+ # surfaces the collision via the worktree-open-failed defer path.
236
+ try:
237
+ verify.checkout_detach(unit.path)
238
+ except verify.GitError:
239
+ pass
240
+ return patch # leave the worktree mounted (branch detached if shared)
241
+
242
+ # success, or a failure we are not keeping: remove the worktree. A failed
243
+ # tree is dirty, so force; a successful unit was committed + merged, so its
244
+ # tree is clean, but force is harmless and tolerant of stray artifacts.
245
+ try:
246
+ verify.worktree_remove(unit.repo_root, unit.path, force=not success)
247
+ except verify.GitError as first_err:
248
+ try:
249
+ # Ordinary dirty-tree case: the plain remove refused stray untracked
250
+ # artifacts, which --force clears. Not a degradation — stay silent.
251
+ verify.worktree_remove(unit.repo_root, unit.path, force=True)
252
+ except verify.GitError as retry_err:
253
+ # gh-139 fingerprint the retry CAN'T fix: a process the just-ended
254
+ # session left running (e.g. pytest recreating `.pytest_cache`) makes
255
+ # the plain remove fail with ENOTEMPTY (first_err), and by then git has
256
+ # already deleted its admin entry `.git/worktrees/<id>` — so the --force
257
+ # retry fails with "is not a working tree" (retry_err). Force can't
258
+ # restore a dropped admin entry, so fall back to git's own reclaim path
259
+ # (plain rmtree + prune) and degrade to a warning: the content already
260
+ # landed, so this is housekeeping, not a run failure. Both git errors
261
+ # go into the message — each half of the fingerprint is diagnostic.
262
+ # The rmtree is confined to the run's worktrees dir: on resume the
263
+ # path comes from persisted state (_reopen_unit), which git validates
264
+ # but rmtree would not.
265
+ removed = _rmtree_confined(unit.path, run_dir)
266
+ verify.worktree_prune(unit.repo_root)
267
+ msg = (
268
+ f"worktree remove failed for {unit.path} ({first_err}); "
269
+ f"force retry failed ({retry_err}); fell back to rmtree+prune"
270
+ )
271
+ if not removed:
272
+ msg += f" (rmtree refused: {unit.path} resolves outside this run's worktrees dir)"
273
+ elif unit.path.exists():
274
+ # rmtree lost the race too (the writer recreated files under it):
275
+ # the dir survives under the gitignored run dir and is reclaimed
276
+ # later by trim_run_dir / clean — note it, don't block on it.
277
+ msg += f" (dir still present: {unit.path})"
278
+ if on_teardown_degraded is not None:
279
+ on_teardown_degraded(msg)
280
+ try:
281
+ if delete_branch and verify.branch_exists(unit.repo_root, unit.branch):
282
+ # the unit's content is already on the target branch (success) or saved
283
+ # to a patch (failure), so a force delete loses nothing — and squash
284
+ # merges leave the branch looking "unmerged" to `git branch -d`.
285
+ # prune above frees a branch whose worktree dir was rmtree'd, so this
286
+ # still runs after a degraded removal.
287
+ verify.delete_branch(unit.repo_root, unit.branch, force=True)
288
+ except verify.GitError as e:
289
+ # second crash door in this teardown tail: branch deletion is likewise
290
+ # post-merge housekeeping, so degrade it rather than raise past here.
291
+ if on_teardown_degraded is not None:
292
+ on_teardown_degraded(f"branch delete failed for {unit.branch}: {e}")
293
+ return patch
294
+
295
+
296
+ def discard_worktree(repo_root: Path, worktree_path: str, branch: str, *, run_dir: Path) -> None:
297
+ """Best-effort force teardown of a worktree + branch by path/name, for
298
+ resume-restart of a crashed/interrupted unit. Tolerant of partial state.
299
+ `worktree_path` arrives from persisted task state, so the rmtree fallback is
300
+ confined to `run_dir`'s worktrees dir (see _rmtree_confined)."""
301
+ if worktree_path:
302
+ wt = Path(worktree_path)
303
+ try:
304
+ if wt.exists():
305
+ verify.worktree_remove(repo_root, wt, force=True)
306
+ except verify.GitError:
307
+ # same gh-139 hazard as close_unit_workspace: a stray process (or a
308
+ # dropped admin entry) can keep `git worktree remove` from clearing
309
+ # the dir. A leftover dir breaks the resume re-mount at this same path
310
+ # (`git worktree add` refuses a non-empty target), so drop to rmtree.
311
+ _rmtree_confined(wt, run_dir)
312
+ # prune git's admin entry regardless (harmless when nothing is stale) so a
313
+ # half-removed worktree can't block that re-mount.
314
+ verify.worktree_prune(repo_root)
315
+ if branch:
316
+ try:
317
+ if verify.branch_exists(repo_root, branch):
318
+ verify.delete_branch(repo_root, branch, force=True)
319
+ except verify.GitError:
320
+ pass