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,202 @@
1
+ """Cross-run pre-answers for deferred-work decisions.
2
+
3
+ A sweep's triage can surface decisions only a human can make. An unattended
4
+ sweep (`--no-prompt`) skips them; an interactive sweep can be abandoned before
5
+ every prompt is answered. Either way the answer is otherwise lost: triage
6
+ re-derives the `decisions` partition from the open ledger on every run, and the
7
+ only record of an answer — the run-scoped `{run_dir}/decisions.json` — does not
8
+ carry across runs, so the next sweep re-surfaces (and re-skips) the same
9
+ decision.
10
+
11
+ This module is the durable carrier. A human answers missed decisions out of band
12
+ (`froid-loop decisions`, or the TUI), the answer is recorded both as a ledger
13
+ `decision:` line and — for build/keep-open — in a project-level
14
+ `.froid-loop/decisions.json` keyed by DW id, and the next sweep consumes it
15
+ instead of asking again (see SweepEngine._decisions_phase). `close` answers need
16
+ no store entry: they are applied to the ledger immediately (status -> done), so
17
+ the entry simply leaves the open set.
18
+
19
+ Layering note: this module sits above sweep.py (it reuses Decision/validate_triage
20
+ and the deterministic ledger helpers). sweep.py imports it lazily to avoid a cycle.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import json
26
+ import re
27
+ from pathlib import Path
28
+
29
+ from . import froidconfig, deferredwork, runs, verify
30
+ from .platform_util import atomic_write_text_confined
31
+ from .sweep import Decision, DecisionOption, validate_triage
32
+
33
+ STORE_REL = Path(".froid-loop") / "decisions.json"
34
+ _TRIAGE_RE = re.compile(r"^triage(?:-(\d+))?\.json$")
35
+
36
+
37
+ def store_path(project: Path) -> Path:
38
+ return project / STORE_REL
39
+
40
+
41
+ # --------------------------------------------------------------- store I/O
42
+
43
+
44
+ def load_pre_answers(project: Path) -> dict[str, dict]:
45
+ """The project-level pre-answer store, {DW-id: {effect,label,intent,...}}.
46
+ Tolerant of a missing or malformed file (returns {})."""
47
+ path = store_path(project)
48
+ if not path.is_file():
49
+ return {}
50
+ try:
51
+ data = json.loads(path.read_text(encoding="utf-8"))
52
+ except (json.JSONDecodeError, OSError):
53
+ return {}
54
+ return data if isinstance(data, dict) else {}
55
+
56
+
57
+ def _write_store(project: Path, data: dict) -> None:
58
+ """#363: via the helper, not a hand-rolled tmp+replace. The store lives at a
59
+ path nothing gitignores, so a failed replace used to strand
60
+ `.froid-loop/decisions.tmp` as an untracked file and hold `worktree_clean`
61
+ False until a human deleted it; the helper removes its temp on any raise.
62
+
63
+ Confined to ``project`` (#593). Refusing to follow a link planted at
64
+ `decisions.json` itself was the behaviour-preserving choice — `os.replace`
65
+ never dereferenced this destination either — and the security one: a driven
66
+ session can write under `.froid-loop/`, so honouring a link planted here would
67
+ hand it a host-side write to any operator-writable path. But that refusal
68
+ stopped at the final component, and the `mkdir` on the line below accepts a
69
+ symlink-to-a-directory, so a link planted at `.froid-loop/` survived the setup
70
+ and redirected both the temp and the publish. The confined writer walks the
71
+ components below `project` `O_NOFOLLOW` and writes through the descriptor
72
+ that walk produced, so the same escalation now costs a refusal instead of a
73
+ host-side write. Permission-neutral: no-follow never inherited a mode either,
74
+ so the store still lands at `0600`.
75
+
76
+ ``require_writable_target=True`` (#597) restores the `PermissionError` a bare
77
+ `Path.write_text` raised here before #363 made the write atomic. The store is
78
+ operator-curated — an operator who marks it read-only is answered rather than
79
+ quietly overwritten and left with the `0444` still showing."""
80
+ path = store_path(project)
81
+ path.parent.mkdir(parents=True, exist_ok=True)
82
+ atomic_write_text_confined(
83
+ path,
84
+ json.dumps(data, indent=2, sort_keys=True),
85
+ confine_root=project,
86
+ require_writable_target=True,
87
+ )
88
+
89
+
90
+ def record_pre_answer(project: Path, dw_id: str, option: DecisionOption, *, date: str) -> None:
91
+ """Persist a chosen option so a future sweep applies it without asking. The
92
+ option's full semantics are stored (not just its key): a later triage may
93
+ renumber options, so the sweep reads effect/intent from here directly."""
94
+ data = load_pre_answers(project)
95
+ data[dw_id] = {
96
+ "key": option.key,
97
+ "label": option.label,
98
+ "effect": option.effect,
99
+ "intent": option.intent,
100
+ "resolution": option.resolution,
101
+ "bundle_name": option.bundle_name,
102
+ "answered_at": date,
103
+ }
104
+ _write_store(project, data)
105
+
106
+
107
+ def prune_pre_answers(project: Path, open_ids: set[str]) -> list[str]:
108
+ """Drop store entries whose DW id is no longer open (built or closed). No-op
109
+ write when nothing is dropped. Returns the dropped ids."""
110
+ data = load_pre_answers(project)
111
+ dropped = [k for k in data if k not in open_ids]
112
+ if dropped:
113
+ for k in dropped:
114
+ del data[k]
115
+ _write_store(project, data)
116
+ return dropped
117
+
118
+
119
+ # ------------------------------------------------------- discovery + apply
120
+
121
+
122
+ def pending_missed_decisions(project: Path) -> list[Decision]:
123
+ """Decisions earlier sweeps surfaced but no one answered: reconstructed from
124
+ every run's persisted triage*.json, kept only when the DW id is still open
125
+ and not already in the pre-answer store. The most recent triage's wording of
126
+ each id wins. Sorted by DW number."""
127
+ paths = froidconfig.load_paths(project)
128
+ ledger = paths.deferred_work
129
+ text = ledger.read_text(encoding="utf-8") if ledger.is_file() else ""
130
+ open_now = deferredwork.open_ids(text)
131
+ if not open_now:
132
+ return []
133
+ answered = set(load_pre_answers(project))
134
+
135
+ # (run-id, cycle) descending == most recent first; run ids sort chronologically
136
+ triage_files: list[tuple[str, int, Path]] = []
137
+ for run_dir in runs.list_run_dirs(project):
138
+ for tp in run_dir.glob("triage*.json"):
139
+ m = _TRIAGE_RE.match(tp.name)
140
+ if m:
141
+ triage_files.append((run_dir.name, int(m.group(1) or 1), tp))
142
+ triage_files.sort(reverse=True)
143
+
144
+ by_id: dict[str, Decision] = {}
145
+ for _run, _cycle, tp in triage_files:
146
+ try:
147
+ rj = json.loads(tp.read_text(encoding="utf-8"))
148
+ except (json.JSONDecodeError, OSError):
149
+ continue
150
+ plan, _errors = validate_triage(rj, None)
151
+ if plan is None:
152
+ continue
153
+ for decision in plan.decisions:
154
+ by_id.setdefault(decision.id, decision) # first (most recent) wins
155
+
156
+ pending = [by_id[i] for i in by_id if i in open_now and i not in answered]
157
+ return sorted(pending, key=lambda d: int(d.id.split("-")[1]))
158
+
159
+
160
+ def apply_pre_answer(
161
+ project: Path, decision: Decision, option: DecisionOption, *, date: str, commit: bool = True
162
+ ) -> None:
163
+ """Record a human's out-of-band answer durably. Always writes a ledger
164
+ `decision:` audit line; `close` also flips the entry to done (so it leaves
165
+ the open set now), while `build`/`keep-open` are saved to the pre-answer
166
+ store for the next sweep to consume. When `commit`, the ledger and store are
167
+ committed on their own (only those paths) — best effort, so a non-git or
168
+ dirty tree never blocks the on-disk record.
169
+
170
+ Precondition: `date` is ISO `YYYY-MM-DD`. The ledger writers raise
171
+ `ValueError` on anything else (it would otherwise land a `status:` line that
172
+ reads as neither open nor done), so a caller building the date itself must
173
+ either guarantee the format or catch it. The option's own free text carries
174
+ no such precondition — it is sanitized, never refused."""
175
+ paths = froidconfig.load_paths(project)
176
+ ledger = paths.deferred_work
177
+ detail = option.resolution or option.intent
178
+ close_note = None
179
+ if option.effect == "close":
180
+ close_note = "closed by human decision" + (
181
+ f": {option.resolution}" if option.resolution else ""
182
+ )
183
+ # ONE locked read->edit->write (#286/#469). As the `append_decision` +
184
+ # `mark_done` pair it was two acquisitions with a window between them, and a
185
+ # rival writer landing there left the entry carrying a decision that says
186
+ # "close it" over a status that still says open. The bytes are identical to
187
+ # the pair's. The commit below stays OUTSIDE any lock — locks are held only
188
+ # around file I/O, never across a subprocess (#286).
189
+ deferredwork.record_decision(
190
+ ledger, decision.id, date, option.label, detail, close_note=close_note
191
+ )
192
+ if option.effect != "close":
193
+ record_pre_answer(project, decision.id, option, date=date)
194
+ if commit:
195
+ try:
196
+ verify.commit_paths(
197
+ project,
198
+ f"chore(decisions): pre-answer {decision.id}",
199
+ [ledger, store_path(project)],
200
+ )
201
+ except verify.GitError:
202
+ pass # files are written; git history is best effort