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.
- froid_loop/__init__.py +11 -0
- froid_loop/__main__.py +12 -0
- froid_loop/adapters/__init__.py +3 -0
- froid_loop/adapters/base.py +254 -0
- froid_loop/adapters/entrypoints.py +63 -0
- froid_loop/adapters/env_fault.py +290 -0
- froid_loop/adapters/generic.py +2013 -0
- froid_loop/adapters/mock.py +49 -0
- froid_loop/adapters/multiplexer.py +914 -0
- froid_loop/adapters/opencode_http.py +1687 -0
- froid_loop/adapters/profile.py +650 -0
- froid_loop/adapters/psmux_backend.py +1428 -0
- froid_loop/adapters/registry.py +322 -0
- froid_loop/adapters/tmux_backend.py +35 -0
- froid_loop/adapters/tmux_base.py +630 -0
- froid_loop/checks.py +187 -0
- froid_loop/cli.py +5041 -0
- froid_loop/data/__init__.py +0 -0
- froid_loop/data/froid_loop_hook.py +228 -0
- froid_loop/data/froid_loop_probe_hook.py +88 -0
- froid_loop/data/plugins/example/plugin.toml +21 -0
- froid_loop/data/plugins/tea/plugin.toml +184 -0
- froid_loop/data/plugins/tea/tea_plugin.py +258 -0
- froid_loop/data/plugins/unity/plugin.toml +140 -0
- froid_loop/data/plugins/unity/unity_assets/FroidLoop.Unity.Editor.asmdef +16 -0
- froid_loop/data/plugins/unity/unity_assets/FroidLoop.Unity.Editor.asmdef.meta +7 -0
- froid_loop/data/plugins/unity/unity_assets/SceneAutoSaveGuard.cs +221 -0
- froid_loop/data/plugins/unity/unity_assets/SceneAutoSaveGuard.cs.meta +11 -0
- froid_loop/data/plugins/unity/unity_assets/_folders/Editor.meta +8 -0
- froid_loop/data/plugins/unity/unity_assets/_folders/FroidLoop.meta +8 -0
- froid_loop/data/plugins/unity/unity_cleanup.py +125 -0
- froid_loop/data/plugins/unity/unity_dialog_probe.py +239 -0
- froid_loop/data/plugins/unity/unity_facts.md +17 -0
- froid_loop/data/plugins/unity/unity_plugin.py +415 -0
- froid_loop/data/plugins/unity/unity_quiesce.py +234 -0
- froid_loop/data/plugins/unity/unity_ready.py +230 -0
- froid_loop/data/plugins/unity/unity_seed_assets.py +298 -0
- froid_loop/data/plugins/unity/unity_setup.py +551 -0
- froid_loop/data/plugins/unity/unity_teardown.py +362 -0
- froid_loop/data/profiles/antigravity.toml +52 -0
- froid_loop/data/profiles/claude.toml +85 -0
- froid_loop/data/profiles/codex.toml +22 -0
- froid_loop/data/profiles/copilot.toml +52 -0
- froid_loop/data/profiles/gemini.toml +26 -0
- froid_loop/data/profiles/opencode.toml +54 -0
- froid_loop/data/settings/core.toml +458 -0
- froid_loop/data/skills/README.md +93 -0
- froid_loop/data/skills/froid-loop-resolve/SKILL.md +288 -0
- froid_loop/data/skills/froid-loop-setup/SKILL.md +161 -0
- froid_loop/data/skills/froid-loop-setup/assets/module-help.csv +3 -0
- froid_loop/data/skills/froid-loop-setup/assets/module.yaml +19 -0
- froid_loop/data/skills/froid-loop-sweep/SKILL.md +100 -0
- froid_loop/data/skills/froid-loop-sweep/automation-mode.md +127 -0
- froid_loop/data/skills/froid-loop-sweep/deferred-work-format.md +302 -0
- froid_loop/data/skills/froid-loop-sweep/migration-mode.md +86 -0
- froid_loop/decisions.py +202 -0
- froid_loop/deferredwork.py +2282 -0
- froid_loop/devcontract.py +892 -0
- froid_loop/diagnostics.py +1104 -0
- froid_loop/documents.py +532 -0
- froid_loop/engine.py +7732 -0
- froid_loop/envvars.py +111 -0
- froid_loop/escalation.py +225 -0
- froid_loop/events.py +266 -0
- froid_loop/fences.py +103 -0
- froid_loop/froidconfig.py +226 -0
- froid_loop/frontmatter.py +526 -0
- froid_loop/gates.py +133 -0
- froid_loop/install.py +2936 -0
- froid_loop/journal.py +178 -0
- froid_loop/machine.py +148 -0
- froid_loop/model.py +898 -0
- froid_loop/operatoractions.py +474 -0
- froid_loop/platform_util.py +1490 -0
- froid_loop/plugins/__init__.py +64 -0
- froid_loop/plugins/bus.py +259 -0
- froid_loop/plugins/context.py +319 -0
- froid_loop/plugins/loader.py +145 -0
- froid_loop/plugins/manifest.py +279 -0
- froid_loop/plugins/model.py +296 -0
- froid_loop/plugins/registry.py +245 -0
- froid_loop/plugins/trust.py +75 -0
- froid_loop/policy.py +1569 -0
- froid_loop/probe.py +1044 -0
- froid_loop/process_host.py +408 -0
- froid_loop/recovery_flow.py +1561 -0
- froid_loop/resolve.py +283 -0
- froid_loop/runs.py +4715 -0
- froid_loop/runsetup.py +1293 -0
- froid_loop/sanitize.py +593 -0
- froid_loop/settings_schema.py +276 -0
- froid_loop/signals.py +160 -0
- froid_loop/sprintstatus.py +609 -0
- froid_loop/statemachine.py +57 -0
- froid_loop/stories.py +615 -0
- froid_loop/stories_engine.py +796 -0
- froid_loop/sweep.py +1892 -0
- froid_loop/tokens.py +196 -0
- froid_loop/tui/__init__.py +11 -0
- froid_loop/tui/app.py +1584 -0
- froid_loop/tui/data.py +840 -0
- froid_loop/tui/launch.py +1003 -0
- froid_loop/tui/screens/__init__.py +1 -0
- froid_loop/tui/screens/dashboard.py +1071 -0
- froid_loop/tui/screens/modals.py +943 -0
- froid_loop/tui/screens/settings_screen.py +477 -0
- froid_loop/tui/settings.py +135 -0
- froid_loop/tui/widgets.py +981 -0
- froid_loop/verify.py +4545 -0
- froid_loop/workspace.py +320 -0
- froid_loop/worktree_flow.py +2301 -0
- froid_loop-0.11.1.dist-info/METADATA +728 -0
- froid_loop-0.11.1.dist-info/RECORD +116 -0
- froid_loop-0.11.1.dist-info/WHEEL +4 -0
- froid_loop-0.11.1.dist-info/entry_points.txt +2 -0
- froid_loop-0.11.1.dist-info/licenses/LICENSE +30 -0
froid_loop/decisions.py
ADDED
|
@@ -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
|