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
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
"""In-process brain for the bundled Test Architect Enterprise (TEA) plugin.
|
|
2
|
+
|
|
3
|
+
The plugin's behaviour is mostly declarative — six ``[workflows.*]`` inject TEA
|
|
4
|
+
sessions at ``post_dev_phase`` / ``pre_commit_gate``, and the generic
|
|
5
|
+
workflow-overlay convention (``<name>_enabled`` / ``<name>_blocking``) turns the
|
|
6
|
+
per-step settings into enable/blocking switches with no code. This module owns
|
|
7
|
+
the two things that need logic:
|
|
8
|
+
|
|
9
|
+
* the **readiness gate** (``validate``) — fail the run fast, with an actionable
|
|
10
|
+
remediation message, when ``require_tea`` is on but the project has no TEA
|
|
11
|
+
install, so an operator who enabled the plugin without installing TEA learns
|
|
12
|
+
immediately instead of watching every injected session flail.
|
|
13
|
+
* **blocking-gate enforcement** (``on_pre_commit``) — when an operator marks a
|
|
14
|
+
gate step blocking (``trace_blocking`` / ``nfr_blocking`` / ``review_blocking``
|
|
15
|
+
= true), parse that gate's latest TEA artifact at commit time and, on a
|
|
16
|
+
FAIL/CONCERNS verdict, escalate the unit (``ctx.veto("pause", …)``) instead of
|
|
17
|
+
letting it land. ``pre_commit`` is the first vetoable stage *after* the
|
|
18
|
+
``pre_commit_gate`` workflows have written their artifacts, and both fire
|
|
19
|
+
unconditionally on every path into a commit — review-converged, skip-review
|
|
20
|
+
(``review.trigger="recommended"`` with no follow-up recommended), and the
|
|
21
|
+
review-budget rescue — so the gates evaluate the exact tree about to commit.
|
|
22
|
+
|
|
23
|
+
Enforcement is **fail-open by design**: if a gate has no blocking flag, no
|
|
24
|
+
artifact, or an artifact that can't be parsed into a known verdict, the commit is
|
|
25
|
+
never blocked. An unknown format must never wrongly stop a commit — only a
|
|
26
|
+
confidently-parsed FAIL/CONCERNS on an operator-marked-blocking gate escalates.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
import json
|
|
32
|
+
from pathlib import Path
|
|
33
|
+
from typing import TYPE_CHECKING, Any
|
|
34
|
+
|
|
35
|
+
from froid_loop.plugins.model import Plugin, PluginError
|
|
36
|
+
|
|
37
|
+
if TYPE_CHECKING:
|
|
38
|
+
from froid_loop.plugins.context import HookContext
|
|
39
|
+
|
|
40
|
+
# The gate steps that expose a ``*_blocking`` flag (the pre_commit_gate steps
|
|
41
|
+
# an operator is likely to enforce). Generation steps (td/atdd/automate) stay
|
|
42
|
+
# advisory by design and are deliberately absent — they are never gate-enforced.
|
|
43
|
+
GATE_STEPS = ("trace", "nfr", "review")
|
|
44
|
+
|
|
45
|
+
# Verdicts that escalate a blocking gate. PASS / WAIVED (an explicit human
|
|
46
|
+
# approval to proceed) / an unknown-or-not-evaluated verdict never block.
|
|
47
|
+
BLOCKING_VERDICTS = frozenset({"FAIL", "CONCERNS"})
|
|
48
|
+
|
|
49
|
+
# Where each gate writes its decision, in probe order (first parseable wins).
|
|
50
|
+
# * "json" — a TEA machine-readable file; read ``gate_status`` / ``decision``
|
|
51
|
+
# * "md_trace" — the trace report's ``## Gate Decision: <verdict>`` heading
|
|
52
|
+
# * "md_nfr" — the NFR report's ``**Overall Status:** / **Gate Status:**`` line
|
|
53
|
+
# * "md_review"— the test-review report's ``**Recommendation**:`` line
|
|
54
|
+
# Paths are relative to TEA's configured ``test_artifacts`` directory. trace
|
|
55
|
+
# emits JSON for exactly this purpose, so it is preferred over its markdown.
|
|
56
|
+
GATE_ARTIFACTS: dict[str, tuple[tuple[str, str], ...]] = {
|
|
57
|
+
"trace": (
|
|
58
|
+
("json", "gate-decision.json"),
|
|
59
|
+
("json", "e2e-trace-summary.json"),
|
|
60
|
+
("md_trace", "traceability-matrix.md"),
|
|
61
|
+
),
|
|
62
|
+
"nfr": (("md_nfr", "nfr-assessment.md"),),
|
|
63
|
+
"review": (("md_review", "test-review.md"),),
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
# Labels that precede a concrete verdict on a markdown gate line, per artifact.
|
|
67
|
+
_MD_LABELS: dict[str, tuple[str, ...]] = {
|
|
68
|
+
"md_trace": ("gate decision",),
|
|
69
|
+
"md_nfr": ("gate status", "overall status", "overall_status"),
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _verdict_token(text: str) -> str | None:
|
|
74
|
+
"""The canonical gate verdict named anywhere in ``text``, or None. CONCERNS /
|
|
75
|
+
FAIL are checked before PASS so a value line that pairs them (rare) errs toward
|
|
76
|
+
blocking; NOT_EVALUATED and anything unrecognized resolve to None (fail-open)."""
|
|
77
|
+
up = text.upper()
|
|
78
|
+
for token in ("CONCERNS", "FAIL", "WAIVED", "PASS"):
|
|
79
|
+
if token in up:
|
|
80
|
+
return token
|
|
81
|
+
return None
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _review_token(line: str) -> str | None:
|
|
85
|
+
"""Map the test-review report's recommendation vocabulary onto a gate verdict:
|
|
86
|
+
Block -> FAIL, Request Changes -> CONCERNS, Approve[/with Comments] -> PASS."""
|
|
87
|
+
low = line.lower()
|
|
88
|
+
if "block" in low:
|
|
89
|
+
return "FAIL"
|
|
90
|
+
if "request change" in low:
|
|
91
|
+
return "CONCERNS"
|
|
92
|
+
if "approve" in low:
|
|
93
|
+
return "PASS"
|
|
94
|
+
return None
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _scan_markdown(text: str, labels: tuple[str, ...], normalize) -> str | None:
|
|
98
|
+
"""First *concrete* labeled line that yields a verdict. Lines carrying ``{}``
|
|
99
|
+
placeholders or ``|`` table syntax are skipped — they are template scaffolding,
|
|
100
|
+
not a generated decision — so a real artifact's value line is what's read."""
|
|
101
|
+
for raw in text.splitlines():
|
|
102
|
+
line = raw.strip()
|
|
103
|
+
if not line or "{" in line or "}" in line or "|" in line:
|
|
104
|
+
continue
|
|
105
|
+
if any(label in line.lower() for label in labels):
|
|
106
|
+
verdict = normalize(line)
|
|
107
|
+
if verdict is not None:
|
|
108
|
+
return verdict
|
|
109
|
+
return None
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class TeaPlugin(Plugin):
|
|
113
|
+
"""Trust-gated in-process plugin (loads only when "tea" is in
|
|
114
|
+
``[plugins] enabled``). Owns the TEA readiness gate + blocking-gate
|
|
115
|
+
enforcement at commit."""
|
|
116
|
+
|
|
117
|
+
# a readiness failure is a deliberate config rejection raised from validate()
|
|
118
|
+
# (fails the run fast), not an isolated hook crash — so fail_closed stays off.
|
|
119
|
+
# Enforcement (on_pre_commit) is fail-open too: a parse failure must never
|
|
120
|
+
# wrongly block, so a raised handler isolating out is the safe outcome.
|
|
121
|
+
fail_closed = False
|
|
122
|
+
|
|
123
|
+
# ----------------------------------------------------------- validation
|
|
124
|
+
|
|
125
|
+
def validate(self, policy: Any) -> None:
|
|
126
|
+
"""Readiness gate: with ``require_tea`` on, refuse to start unless TEA is
|
|
127
|
+
installed in the project (``_froid/tea/config.yaml`` present). Raising here
|
|
128
|
+
propagates through ``registry.validate`` so the run fails fast at startup
|
|
129
|
+
rather than launching six sessions that each invoke a missing workflow."""
|
|
130
|
+
if not self._require_tea():
|
|
131
|
+
return
|
|
132
|
+
config = self._tea_config_path(self._project_root())
|
|
133
|
+
if not config.is_file():
|
|
134
|
+
raise PluginError(
|
|
135
|
+
"plugin 'tea': the Test Architect Enterprise (TEA) module is not "
|
|
136
|
+
f"installed in this project (missing {config}). Install it with "
|
|
137
|
+
"`npx froid-plane install` and choose 'Test Architect', or set "
|
|
138
|
+
"require_tea = false under [plugins.tea] in .froid-loop/policy.toml "
|
|
139
|
+
"to run the TEA workflows advisory-only without it."
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
# --------------------------------------------------------- enforcement
|
|
143
|
+
|
|
144
|
+
def on_pre_commit(self, ctx: "HookContext") -> None:
|
|
145
|
+
"""Blocking-gate enforcement. For each gate an operator marked blocking,
|
|
146
|
+
parse its latest TEA artifact under the configured ``test_artifacts`` dir
|
|
147
|
+
and, on a FAIL/CONCERNS verdict, ``pause``-veto so the unit escalates for
|
|
148
|
+
human review instead of committing.
|
|
149
|
+
|
|
150
|
+
Fail-open throughout: a gate with no blocking flag, no artifact, or an
|
|
151
|
+
unparseable artifact contributes nothing. Only confidently-parsed
|
|
152
|
+
blocking verdicts escalate. ``pre_commit`` honors only a ``pause`` veto
|
|
153
|
+
(a COMMITTING task has no legal move to DEFERRED), which is the right
|
|
154
|
+
"blocking gate failed" semantic — escalate-for-human."""
|
|
155
|
+
gates = [g for g in GATE_STEPS if bool(self.settings.get(f"{g}_blocking"))]
|
|
156
|
+
if not gates:
|
|
157
|
+
return # nothing flagged blocking -> advisory only, no artifact read
|
|
158
|
+
|
|
159
|
+
# Resolve the tree TEA wrote into: the unit's worktree (where the
|
|
160
|
+
# pre_commit_gate sessions just ran), falling back to the repo root.
|
|
161
|
+
# Under isolation = none the two coincide.
|
|
162
|
+
root = ctx.worktree or ctx.repo_root
|
|
163
|
+
if not root:
|
|
164
|
+
return # fail-open: no tree to inspect
|
|
165
|
+
artifacts_dir = self._artifacts_dir(Path(root))
|
|
166
|
+
|
|
167
|
+
verdicts: dict[str, str] = {}
|
|
168
|
+
failed: list[tuple[str, str]] = []
|
|
169
|
+
for gate in gates:
|
|
170
|
+
verdict = self._gate_verdict(gate, artifacts_dir)
|
|
171
|
+
if verdict is None:
|
|
172
|
+
continue # fail-open: missing / unparseable / not-evaluated
|
|
173
|
+
verdicts[gate] = verdict
|
|
174
|
+
if verdict in BLOCKING_VERDICTS:
|
|
175
|
+
failed.append((gate, verdict))
|
|
176
|
+
|
|
177
|
+
if verdicts:
|
|
178
|
+
# breadcrumb for the operator / journal: what each gate decided.
|
|
179
|
+
ctx.shared.setdefault("tea_gates", {}).update(verdicts)
|
|
180
|
+
if failed:
|
|
181
|
+
detail = ", ".join(f"{gate}={verdict}" for gate, verdict in failed)
|
|
182
|
+
ctx.veto(
|
|
183
|
+
"pause",
|
|
184
|
+
f"TEA blocking gate(s) failed for {ctx.story_key or 'unit'}: {detail}. "
|
|
185
|
+
f"Review the gate artifacts under {artifacts_dir}, resolve the "
|
|
186
|
+
"findings, then resume the run.",
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
# -------------------------------------------------------------- helpers
|
|
190
|
+
|
|
191
|
+
def _require_tea(self) -> bool:
|
|
192
|
+
return bool(self.settings.get("require_tea", True))
|
|
193
|
+
|
|
194
|
+
def _project_root(self) -> Path:
|
|
195
|
+
"""The project root, resolved the way the engine resolves it: the run's
|
|
196
|
+
working directory (``froid-loop``'s ``--project`` defaults to cwd, and the
|
|
197
|
+
process is not chdir'd into a worktree at construction time). validate()
|
|
198
|
+
has no ``HookContext`` to read ``repo_root`` from, so cwd is the available
|
|
199
|
+
signal; ``on_pre_commit`` uses ``ctx.worktree`` / ``ctx.repo_root`` for the
|
|
200
|
+
authoritative per-unit root."""
|
|
201
|
+
return Path.cwd()
|
|
202
|
+
|
|
203
|
+
def _tea_config_path(self, root: Path) -> Path:
|
|
204
|
+
return root / "_froid" / "tea" / "config.yaml"
|
|
205
|
+
|
|
206
|
+
def _artifacts_dir(self, root: Path) -> Path:
|
|
207
|
+
"""TEA's configured ``test_artifacts`` directory, resolved against ``root``.
|
|
208
|
+
|
|
209
|
+
Reads the ``test_artifacts`` line from ``_froid/tea/config.yaml`` (a light
|
|
210
|
+
line parse — no YAML dependency, and robust if the file is absent), expands
|
|
211
|
+
the ``{project-root}`` / ``{output_folder}`` tokens, and falls back to the
|
|
212
|
+
installer default. A relative value is joined onto ``root``."""
|
|
213
|
+
value = "{project-root}/_froid-output/test-artifacts"
|
|
214
|
+
config = self._tea_config_path(root)
|
|
215
|
+
try:
|
|
216
|
+
for raw in config.read_text(encoding="utf-8").splitlines():
|
|
217
|
+
line = raw.strip()
|
|
218
|
+
if line.startswith("test_artifacts:"):
|
|
219
|
+
value = line.split(":", 1)[1].strip().strip("\"'")
|
|
220
|
+
break
|
|
221
|
+
except OSError:
|
|
222
|
+
pass # fail-open: use the default location
|
|
223
|
+
value = value.replace("{project-root}", str(root)).replace(
|
|
224
|
+
"{output_folder}", str(root / "_froid-output")
|
|
225
|
+
)
|
|
226
|
+
path = Path(value)
|
|
227
|
+
return path if path.is_absolute() else root / value
|
|
228
|
+
|
|
229
|
+
def _gate_verdict(self, gate: str, artifacts_dir: Path) -> str | None:
|
|
230
|
+
"""The latest verdict for ``gate`` from its artifacts, or None. Probes each
|
|
231
|
+
candidate in order, returning the first that parses to a known verdict;
|
|
232
|
+
any read/parse error on a candidate is swallowed (fail-open) and the next
|
|
233
|
+
is tried."""
|
|
234
|
+
for kind, filename in GATE_ARTIFACTS.get(gate, ()):
|
|
235
|
+
path = artifacts_dir / filename
|
|
236
|
+
if not path.is_file():
|
|
237
|
+
continue
|
|
238
|
+
try:
|
|
239
|
+
verdict = self._parse_artifact(kind, path)
|
|
240
|
+
except Exception: # nosec B112 - fail-open: a parse error never blocks
|
|
241
|
+
continue
|
|
242
|
+
if verdict is not None:
|
|
243
|
+
return verdict
|
|
244
|
+
return None
|
|
245
|
+
|
|
246
|
+
def _parse_artifact(self, kind: str, path: Path) -> str | None:
|
|
247
|
+
"""Extract a gate verdict from one artifact, by kind."""
|
|
248
|
+
text = path.read_text(encoding="utf-8")
|
|
249
|
+
if kind == "json":
|
|
250
|
+
data = json.loads(text)
|
|
251
|
+
if isinstance(data, dict):
|
|
252
|
+
raw = data.get("gate_status") or data.get("decision")
|
|
253
|
+
if isinstance(raw, str):
|
|
254
|
+
return _verdict_token(raw)
|
|
255
|
+
return None
|
|
256
|
+
if kind == "md_review":
|
|
257
|
+
return _scan_markdown(text, ("recommendation",), _review_token)
|
|
258
|
+
return _scan_markdown(text, _MD_LABELS[kind], lambda line: _verdict_token(line))
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
# Bundled Unity game-engine plugin for froid-loop.
|
|
2
|
+
#
|
|
3
|
+
# This is the proof that the plugin framework is general enough to carry the
|
|
4
|
+
# whole game-engine layer with no engine-specific code in core. Unity drives a
|
|
5
|
+
# project whose dev/sweep cycle needs a live Editor + MCP bridge.
|
|
6
|
+
#
|
|
7
|
+
# Opt in from .froid-loop/policy.toml:
|
|
8
|
+
# [plugins]
|
|
9
|
+
# enabled = ["unity"] # [python] module loads only when enabled
|
|
10
|
+
#
|
|
11
|
+
# [plugins.unity]
|
|
12
|
+
# editor_mode = "shared" # shared (live Editor, isolation=none)
|
|
13
|
+
# # | per_worktree (one Editor per worktree, isolation=worktree)
|
|
14
|
+
# mcp = "ivanmurzak" # ivanmurzak | coplaydev
|
|
15
|
+
#
|
|
16
|
+
# Override per project by copying this dir to .froid-loop/plugins/unity/ and
|
|
17
|
+
# editing it (the project-local copy wins, and its dir becomes {scripts}).
|
|
18
|
+
#
|
|
19
|
+
# Scene auto-save guard: Unity-MCP GameObject tools mark scenes dirty but never
|
|
20
|
+
# save, so a shared editor's scene sits chronically dirty — and that dirty state
|
|
21
|
+
# is what raises the two run-stalling modal dialogs (scene-changed-on-disk reload,
|
|
22
|
+
# and save-before-quit), which freeze the MCP dispatch loop. This plugin seeds an
|
|
23
|
+
# editor-only auto-save guard (Assets/FroidLoop/Editor/, from unity_assets/) into
|
|
24
|
+
# the project so no such modal ever appears. The seeded guard is committed into
|
|
25
|
+
# the consumer project by story-finalize's `git add -A` — that is intended (the
|
|
26
|
+
# guard travels with the repo). Disable with install_scene_guard = false.
|
|
27
|
+
|
|
28
|
+
[plugin]
|
|
29
|
+
name = "unity"
|
|
30
|
+
version = "1.0.0"
|
|
31
|
+
api_version = 1
|
|
32
|
+
description = "Drive a Unity project whose dev/sweep cycle needs a live Editor + MCP bridge."
|
|
33
|
+
author = "froid-loop"
|
|
34
|
+
# MCP-generated skills are gitignored; seed them into per_worktree checkouts so
|
|
35
|
+
# the agent's CLI can reach the worktree Editor's tools.
|
|
36
|
+
seed_globs = [".claude/skills/*"]
|
|
37
|
+
|
|
38
|
+
# The in-process module owns the engine glue: the readiness gate, per_worktree
|
|
39
|
+
# Editor setup/teardown, MCP agent routing, Library priming, and the
|
|
40
|
+
# editor_mode<->scm.isolation coupling validation. It hooks the engine's
|
|
41
|
+
# lifecycle stages (pre_ready_gate / pre_worktree_setup / pre_worktree_teardown)
|
|
42
|
+
# and vetoes (defers) the unit when the Editor can't be made ready. It also
|
|
43
|
+
# quiesces the Editor around a failed-attempt rollback (pre_rollback /
|
|
44
|
+
# post_rollback) so a git reset --hard can't leave a shared Editor showing a
|
|
45
|
+
# run-freezing scene-changed-on-disk modal — best effort, never blocks the reset.
|
|
46
|
+
# On pre_session it appends the scene-save discipline (unity_facts.md) to every
|
|
47
|
+
# dev/review prompt, and (when dialog_probe is on) it launches + reaps a detached,
|
|
48
|
+
# DETECT-ONLY modal-dialog probe (pre_run / post_run / pre_worktree_setup).
|
|
49
|
+
[python]
|
|
50
|
+
module = "unity_plugin.py"
|
|
51
|
+
class = "UnityPlugin"
|
|
52
|
+
|
|
53
|
+
[[settings]]
|
|
54
|
+
key = "editor_mode"
|
|
55
|
+
type = "select"
|
|
56
|
+
options = ["shared", "per_worktree"]
|
|
57
|
+
default = "shared"
|
|
58
|
+
label = "Editor mode"
|
|
59
|
+
help = "shared = agent works in place on the live Editor's checkout (requires scm.isolation = none); per_worktree = one managed Editor per worktree (requires scm.isolation = worktree)"
|
|
60
|
+
|
|
61
|
+
[[settings]]
|
|
62
|
+
key = "mcp"
|
|
63
|
+
type = "select"
|
|
64
|
+
options = ["ivanmurzak", "coplaydev"]
|
|
65
|
+
default = "ivanmurzak"
|
|
66
|
+
label = "Editor MCP"
|
|
67
|
+
help = "which Editor MCP server the scripts target"
|
|
68
|
+
|
|
69
|
+
[[settings]]
|
|
70
|
+
key = "unity_path"
|
|
71
|
+
type = "str"
|
|
72
|
+
default = ""
|
|
73
|
+
label = "Unity editor path"
|
|
74
|
+
help = "Editor binary used to launch a per_worktree Editor; '' auto-detects. Ignored in shared mode."
|
|
75
|
+
|
|
76
|
+
[[settings]]
|
|
77
|
+
key = "ready_timeout_sec"
|
|
78
|
+
type = "int"
|
|
79
|
+
default = 600
|
|
80
|
+
min = 1
|
|
81
|
+
label = "Readiness timeout (sec)"
|
|
82
|
+
help = "how long the readiness gate blocks for the Editor + MCP to come up"
|
|
83
|
+
|
|
84
|
+
[[settings]]
|
|
85
|
+
key = "ready_grace_sec"
|
|
86
|
+
type = "int"
|
|
87
|
+
default = -1
|
|
88
|
+
label = "Readiness grace (sec)"
|
|
89
|
+
help = "seconds to wait before the first readiness probe (-1 = auto: per_worktree waits for a cold Editor, shared does not)"
|
|
90
|
+
|
|
91
|
+
[[settings]]
|
|
92
|
+
key = "install_scene_guard"
|
|
93
|
+
type = "bool"
|
|
94
|
+
default = true
|
|
95
|
+
label = "Install scene auto-save guard"
|
|
96
|
+
help = "seed an editor-only auto-save guard into the project so chronically-dirty scenes never raise the run-stalling scene-changed-on-disk / save-before-quit modal dialogs. The seeded guard is committed into the consumer project by story-finalize's git add -A (intended). false disables seeding."
|
|
97
|
+
|
|
98
|
+
[[settings]]
|
|
99
|
+
key = "scene_guard_dir"
|
|
100
|
+
type = "str"
|
|
101
|
+
default = "Assets/FroidLoop/Editor"
|
|
102
|
+
label = "Scene guard install dir"
|
|
103
|
+
help = "project-relative dir the scene auto-save guard is seeded into (must live under the project's asset root, e.g. Assets/)"
|
|
104
|
+
|
|
105
|
+
[[settings]]
|
|
106
|
+
key = "quiesce_on_rollback"
|
|
107
|
+
type = "bool"
|
|
108
|
+
default = true
|
|
109
|
+
label = "Quiesce Editor before rollback"
|
|
110
|
+
help = "save open scenes + open an empty untitled scene via the MCP CLI before git reset, refresh assets after; best-effort, never blocks the rollback"
|
|
111
|
+
|
|
112
|
+
[[settings]]
|
|
113
|
+
key = "quiesce_timeout_sec"
|
|
114
|
+
type = "int"
|
|
115
|
+
default = 60
|
|
116
|
+
min = 1
|
|
117
|
+
label = "Rollback quiesce timeout (sec)"
|
|
118
|
+
help = "overall budget for the pre/post-rollback quiesce helper; the loop hard-kills it past this so a wedged Editor can never stall a rollback"
|
|
119
|
+
|
|
120
|
+
[[settings]]
|
|
121
|
+
key = "dialog_probe"
|
|
122
|
+
type = "bool"
|
|
123
|
+
default = false
|
|
124
|
+
label = "Detect-only modal-dialog probe"
|
|
125
|
+
help = "launch a detached, DETECT-ONLY probe (unity_dialog_probe.py) that watches — via xdotool, X11/Linux only — for the run-freezing Unity modal dialogs and REPORTS them (JSONL + ATTENTION + notify-send). It never clicks or keys anything; it is the last-resort observability net for a modal the scene guard + prompt facts did not prevent. No-op where DISPLAY is unset or xdotool is absent (Windows/macOS out of scope). Off by default."
|
|
126
|
+
|
|
127
|
+
[[settings]]
|
|
128
|
+
key = "dialog_probe_interval_sec"
|
|
129
|
+
type = "int"
|
|
130
|
+
default = 5
|
|
131
|
+
min = 1
|
|
132
|
+
label = "Dialog probe poll interval (sec)"
|
|
133
|
+
help = "how often the dialog probe polls xdotool for a visible Unity modal dialog"
|
|
134
|
+
|
|
135
|
+
[[settings]]
|
|
136
|
+
key = "dialog_probe_notify"
|
|
137
|
+
type = "bool"
|
|
138
|
+
default = true
|
|
139
|
+
label = "Dialog probe desktop notification"
|
|
140
|
+
help = "on a fresh detection, also fire a best-effort notify-send desktop notification (guarded by shutil.which); the JSONL + ATTENTION records are written regardless"
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "FroidLoop.Unity.Editor",
|
|
3
|
+
"rootNamespace": "FroidLoop.Unity.Editor",
|
|
4
|
+
"references": [],
|
|
5
|
+
"includePlatforms": [
|
|
6
|
+
"Editor"
|
|
7
|
+
],
|
|
8
|
+
"excludePlatforms": [],
|
|
9
|
+
"allowUnsafeCode": false,
|
|
10
|
+
"overrideReferences": false,
|
|
11
|
+
"precompiledReferences": [],
|
|
12
|
+
"autoReferenced": false,
|
|
13
|
+
"defineConstraints": [],
|
|
14
|
+
"versionDefines": [],
|
|
15
|
+
"noEngineReferences": false
|
|
16
|
+
}
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
// froid-loop-scene-guard-version: 1.0.0
|
|
2
|
+
//
|
|
3
|
+
// Scene auto-save guard for froid-loop-driven Unity projects.
|
|
4
|
+
//
|
|
5
|
+
// Unity-MCP GameObject tools (com.ivanmurzak.unity.mcp) call MarkSceneDirty and
|
|
6
|
+
// never save, so a project driven by the shared editor accumulates a chronically
|
|
7
|
+
// dirty scene. That dirty state is what makes Unity raise the two run-stalling
|
|
8
|
+
// modal dialogs:
|
|
9
|
+
// * "Scene '<name>' has been changed on disk. Reload the scene?" — pops when git
|
|
10
|
+
// or an agent rewrites the open dirty scene's .unity file on disk;
|
|
11
|
+
// * "Do you want to save the changes you made ...?" — pops on editor quit.
|
|
12
|
+
// A modal dialog freezes EditorApplication.update, which the MCP plugin uses to
|
|
13
|
+
// dispatch tool calls, so every subsequent MCP call times out and the run stalls.
|
|
14
|
+
//
|
|
15
|
+
// This guard fixes the root cause: it keeps loaded, on-disk scenes clean by
|
|
16
|
+
// debounce-saving them shortly after they go dirty, and on quit it saves (or, for
|
|
17
|
+
// an unsaveable untitled scene, discards) so no modal ever appears. Untitled scenes
|
|
18
|
+
// are never saved automatically — that would pop a "Save As" modal, exactly what we
|
|
19
|
+
// are avoiding.
|
|
20
|
+
//
|
|
21
|
+
// froid-loop seeds this file into consumer projects; story-finalize's `git add -A`
|
|
22
|
+
// then commits it into the project — that is intended (the guard travels with the
|
|
23
|
+
// repo so any editor opening it is protected). Toggle it from the FroidLoop menu.
|
|
24
|
+
using System.Collections.Generic;
|
|
25
|
+
using UnityEditor;
|
|
26
|
+
using UnityEditor.SceneManagement;
|
|
27
|
+
using UnityEngine;
|
|
28
|
+
using UnityEngine.SceneManagement;
|
|
29
|
+
|
|
30
|
+
namespace FroidLoop.Unity.Editor
|
|
31
|
+
{
|
|
32
|
+
[InitializeOnLoad]
|
|
33
|
+
internal static class SceneAutoSaveGuard
|
|
34
|
+
{
|
|
35
|
+
// EditorPrefs toggle (default ON) + its two menu items.
|
|
36
|
+
private const string EnabledPref = "FroidLoop.SceneAutoSaveGuard.Enabled";
|
|
37
|
+
private const string EnabledMenu = "FroidLoop/Scene Auto-Save Guard";
|
|
38
|
+
private const string SaveNowMenu = "FroidLoop/Save Open Scenes Now";
|
|
39
|
+
|
|
40
|
+
// Debounce window: each sceneDirtied event pushes the deadline out this far,
|
|
41
|
+
// so a burst of MCP edits collapses into a single save once they settle.
|
|
42
|
+
private const double DebounceSeconds = 5.0;
|
|
43
|
+
|
|
44
|
+
// The timeSinceStartup deadline for the pending save; < 0 means "nothing
|
|
45
|
+
// pending" — the cheap early-out the per-frame update handler checks first.
|
|
46
|
+
private static double s_dueAt = -1.0;
|
|
47
|
+
|
|
48
|
+
static SceneAutoSaveGuard()
|
|
49
|
+
{
|
|
50
|
+
EditorSceneManager.sceneDirtied += OnSceneDirtied;
|
|
51
|
+
EditorApplication.update += OnUpdate;
|
|
52
|
+
EditorApplication.wantsToQuit += OnWantsToQuit;
|
|
53
|
+
EditorApplication.quitting += OnQuitting;
|
|
54
|
+
// A scene can already be dirty from before this domain reload — an MCP
|
|
55
|
+
// tool dirtied it, then a recompile reloaded the domain and reset our
|
|
56
|
+
// pending state. Arm immediately so that dirty scene still gets flushed.
|
|
57
|
+
if (HasDirtyPathedScene())
|
|
58
|
+
{
|
|
59
|
+
Arm();
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
private static bool Enabled => EditorPrefs.GetBool(EnabledPref, true);
|
|
64
|
+
|
|
65
|
+
private static void Arm()
|
|
66
|
+
{
|
|
67
|
+
s_dueAt = EditorApplication.timeSinceStartup + DebounceSeconds;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
private static void OnSceneDirtied(Scene scene)
|
|
71
|
+
{
|
|
72
|
+
Arm();
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
private static void OnUpdate()
|
|
76
|
+
{
|
|
77
|
+
// Cheap early-out: nothing pending is the overwhelmingly common case, so
|
|
78
|
+
// this handler must cost next to nothing when idle.
|
|
79
|
+
if (s_dueAt < 0.0)
|
|
80
|
+
{
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
if (EditorApplication.timeSinceStartup < s_dueAt)
|
|
84
|
+
{
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
if (!Enabled)
|
|
88
|
+
{
|
|
89
|
+
s_dueAt = -1.0; // toggled off while a save was pending — drop it
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
// Never save mid-transition: entering/exiting play mode, compiling,
|
|
93
|
+
// importing/refreshing assets, building a player, or editing a prefab in
|
|
94
|
+
// isolation (the open scenes aren't the active editing target then).
|
|
95
|
+
// Re-arm and try again on a later frame.
|
|
96
|
+
if (EditorApplication.isPlayingOrWillChangePlaymode
|
|
97
|
+
|| EditorApplication.isCompiling
|
|
98
|
+
|| EditorApplication.isUpdating
|
|
99
|
+
|| BuildPipeline.isBuildingPlayer
|
|
100
|
+
|| PrefabStageUtility.GetCurrentPrefabStage() != null)
|
|
101
|
+
{
|
|
102
|
+
Arm();
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
s_dueAt = -1.0;
|
|
106
|
+
SaveDirtyPathedScenes("auto-saved");
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
private static bool OnWantsToQuit()
|
|
110
|
+
{
|
|
111
|
+
// Even with the guard off, never block the quit — that would defeat the
|
|
112
|
+
// whole point (a frozen editor). Just don't touch the scenes.
|
|
113
|
+
if (Enabled)
|
|
114
|
+
{
|
|
115
|
+
SaveDirtyPathedScenes("auto-saved");
|
|
116
|
+
// A dirty *untitled* scene has no path to save to; leaving it would
|
|
117
|
+
// pop the "save changes before quitting?" modal we exist to prevent.
|
|
118
|
+
// Discard it by swapping in a fresh empty single scene.
|
|
119
|
+
string discarded = DirtyUntitledSceneNames();
|
|
120
|
+
if (discarded.Length > 0)
|
|
121
|
+
{
|
|
122
|
+
Debug.LogWarning(
|
|
123
|
+
"[SceneAutoSaveGuard] discarding unsaved untitled scene(s) on quit: "
|
|
124
|
+
+ discarded);
|
|
125
|
+
EditorSceneManager.NewScene(NewSceneSetup.EmptyScene, NewSceneMode.Single);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
// ALWAYS allow the quit to proceed.
|
|
129
|
+
return true;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
private static void OnQuitting()
|
|
133
|
+
{
|
|
134
|
+
// Backstop: wantsToQuit already saved, but another handler in the quit
|
|
135
|
+
// chain may have re-dirtied a pathed scene after it. Save once more.
|
|
136
|
+
if (Enabled)
|
|
137
|
+
{
|
|
138
|
+
SaveDirtyPathedScenes("auto-saved");
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
[MenuItem(EnabledMenu, priority = 200)]
|
|
143
|
+
private static void ToggleEnabled()
|
|
144
|
+
{
|
|
145
|
+
bool now = !Enabled;
|
|
146
|
+
EditorPrefs.SetBool(EnabledPref, now);
|
|
147
|
+
Menu.SetChecked(EnabledMenu, now);
|
|
148
|
+
if (!now)
|
|
149
|
+
{
|
|
150
|
+
s_dueAt = -1.0; // cancel any pending save when the guard is disabled
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
[MenuItem(EnabledMenu, validate = true)]
|
|
155
|
+
private static bool ToggleEnabledValidate()
|
|
156
|
+
{
|
|
157
|
+
// Reflect the persisted toggle in the menu's checkmark each time it opens.
|
|
158
|
+
Menu.SetChecked(EnabledMenu, Enabled);
|
|
159
|
+
return true;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
[MenuItem(SaveNowMenu, priority = 201)]
|
|
163
|
+
private static void SaveOpenScenesNow()
|
|
164
|
+
{
|
|
165
|
+
s_dueAt = -1.0; // immediate flush, bypassing the debounce
|
|
166
|
+
SaveDirtyPathedScenes("auto-saved");
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
private static bool HasDirtyPathedScene()
|
|
170
|
+
{
|
|
171
|
+
for (int i = 0; i < SceneManager.sceneCount; i++)
|
|
172
|
+
{
|
|
173
|
+
Scene scene = SceneManager.GetSceneAt(i);
|
|
174
|
+
if (scene.isDirty && !string.IsNullOrEmpty(scene.path))
|
|
175
|
+
{
|
|
176
|
+
return true;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
return false;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Save every loaded scene that is dirty and has a path on disk. Untitled
|
|
183
|
+
// scenes (empty path) are always skipped so we never trigger a "Save As"
|
|
184
|
+
// modal. Returns the number saved; logs the paths when at least one saved.
|
|
185
|
+
private static int SaveDirtyPathedScenes(string verb)
|
|
186
|
+
{
|
|
187
|
+
var saved = new List<string>();
|
|
188
|
+
for (int i = 0; i < SceneManager.sceneCount; i++)
|
|
189
|
+
{
|
|
190
|
+
Scene scene = SceneManager.GetSceneAt(i);
|
|
191
|
+
if (scene.isDirty
|
|
192
|
+
&& !string.IsNullOrEmpty(scene.path)
|
|
193
|
+
&& EditorSceneManager.SaveScene(scene))
|
|
194
|
+
{
|
|
195
|
+
saved.Add(scene.path);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
if (saved.Count > 0)
|
|
199
|
+
{
|
|
200
|
+
Debug.Log(
|
|
201
|
+
"[SceneAutoSaveGuard] " + verb + " " + saved.Count + " scene(s): "
|
|
202
|
+
+ string.Join(", ", saved));
|
|
203
|
+
}
|
|
204
|
+
return saved.Count;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
private static string DirtyUntitledSceneNames()
|
|
208
|
+
{
|
|
209
|
+
var names = new List<string>();
|
|
210
|
+
for (int i = 0; i < SceneManager.sceneCount; i++)
|
|
211
|
+
{
|
|
212
|
+
Scene scene = SceneManager.GetSceneAt(i);
|
|
213
|
+
if (scene.isDirty && string.IsNullOrEmpty(scene.path))
|
|
214
|
+
{
|
|
215
|
+
names.Add(string.IsNullOrEmpty(scene.name) ? "<untitled>" : scene.name);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
return string.Join(", ", names);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}
|