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,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,7 @@
1
+ fileFormatVersion: 2
2
+ guid: d3183d5c02f900e680aa4d788779cd91
3
+ AssemblyDefinitionImporter:
4
+ externalObjects: {}
5
+ userData:
6
+ assetBundleName:
7
+ assetBundleVariant:
@@ -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
+ }
@@ -0,0 +1,11 @@
1
+ fileFormatVersion: 2
2
+ guid: 03213cb31364059c331c65ce16ba3356
3
+ MonoImporter:
4
+ externalObjects: {}
5
+ serializedVersion: 2
6
+ defaultReferences: []
7
+ executionOrder: 0
8
+ icon: {instanceID: 0}
9
+ userData:
10
+ assetBundleName:
11
+ assetBundleVariant:
@@ -0,0 +1,8 @@
1
+ fileFormatVersion: 2
2
+ guid: 3edfe262bc470b24adffcffcbeb3ff11
3
+ folderAsset: yes
4
+ DefaultImporter:
5
+ externalObjects: {}
6
+ userData:
7
+ assetBundleName:
8
+ assetBundleVariant:
@@ -0,0 +1,8 @@
1
+ fileFormatVersion: 2
2
+ guid: 9f46d91abc0bd668334c07a3ce41e1d2
3
+ folderAsset: yes
4
+ DefaultImporter:
5
+ externalObjects: {}
6
+ userData:
7
+ assetBundleName:
8
+ assetBundleVariant: