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,230 @@
1
+ #!/usr/bin/env python3
2
+ """Readiness gate for the froid-loop Unity engine plugin.
3
+
4
+ Blocks until the Unity Editor + MCP bridge are ready to accept commands, so a
5
+ dev/sweep session never starts against a half-open Editor. The engine runs this
6
+ as the plugin's ``ready_cmd`` and injects its configuration via the environment.
7
+
8
+ Supported MCP servers (FROID_LOOP_ENGINE_MCP):
9
+ - ivanmurzak : shells out to the Unity-MCP CLI's ``wait-for-ready`` (passing an
10
+ explicit ``--timeout`` — the CLI's own default is only 120s — and
11
+ retrying so a fast connection-refused against a not-yet-listening
12
+ Editor doesn't abort the gate early). Because the per_worktree
13
+ Editor hosts its *own* MCP server (unity_setup launches it with
14
+ ``--start-server true``), the Editor↔server bridge — and therefore
15
+ ``wait-for-ready`` — comes up *without* any MCP client connected, so
16
+ it is a sound readiness signal before the agent ever runs. That is
17
+ the default gate. Optionally (FROID_LOOP_UNITY_READY_TOOL set to a
18
+ tool name) it also confirms with a real read-only ``run-tool``
19
+ round-trip that actually executes in the Editor. This is OFF by
20
+ default: tool names are version-specific (e.g. ``ping``, ``unity-
21
+ tool-list``) and some return null/non-zero even when healthy, so the
22
+ round-trip is opt-in for operators who've picked a tool that works.
23
+ - coplaydev : connectivity check against the MCP HTTP server (best effort —
24
+ see note below; override engine.ready_cmd for a stricter probe).
25
+
26
+ Cold-launch grace: a per_worktree Editor is launched fresh per unit and needs
27
+ time to start (and import) before it can answer at all. The gate waits
28
+ FROID_LOOP_ENGINE_READY_GRACE seconds before the first probe; ``-1`` (the default)
29
+ auto-picks 120s for per_worktree and 0s for shared (a warm, already-open Editor).
30
+ The grace counts against the overall readiness budget.
31
+
32
+ Exit 0 = ready; non-zero = not ready (the engine defers the unit and notifies).
33
+
34
+ Env knobs (all optional):
35
+ FROID_LOOP_ENGINE_MCP ivanmurzak | coplaydev (default ivanmurzak)
36
+ FROID_LOOP_ENGINE_EDITOR_MODE shared | per_worktree (selects the grace default)
37
+ FROID_LOOP_WORKTREE project the Editor has open (falls back to REPO_ROOT)
38
+ FROID_LOOP_REPO_ROOT main repo root
39
+ FROID_LOOP_ENGINE_READY_TIMEOUT seconds to keep polling (default 600)
40
+ FROID_LOOP_ENGINE_READY_GRACE pre-probe delay seconds; -1=auto (default -1)
41
+ FROID_LOOP_UNITY_READY_TOOL opt-in read-only run-tool to confirm (default empty = off)
42
+ UNITY_MCP_CLI IvanMurzak CLI binary (default unity-mcp-cli)
43
+ UNITY_MCP_URL CoplayDev MCP server URL (default http://localhost:8080)
44
+
45
+ NOTE: the exact IvanMurzak CLI name/subcommand and CoplayDev readiness endpoint
46
+ move between releases — verify against the version installed in your project and
47
+ override ``engine.ready_cmd`` in a project-local plugin if they differ.
48
+ """
49
+
50
+ from __future__ import annotations
51
+
52
+ import os
53
+ import shutil
54
+ import socket
55
+ import subprocess
56
+ import sys
57
+ import time
58
+ from urllib.parse import urlparse
59
+
60
+ # return this many seconds before the engine's outer timeout would fire, so the
61
+ # gate yields a clean "not ready" rc rather than being hard-killed mid-probe.
62
+ _SAFETY_MARGIN = 10.0
63
+ # default pre-probe grace per editor_mode when FROID_LOOP_ENGINE_READY_GRACE = -1.
64
+ _AUTO_GRACE = {"per_worktree": 120.0, "shared": 0.0}
65
+
66
+
67
+ def _project() -> str:
68
+ return os.environ.get("FROID_LOOP_WORKTREE") or os.environ.get("FROID_LOOP_REPO_ROOT") or "."
69
+
70
+
71
+ def _timeout() -> float:
72
+ try:
73
+ return float(os.environ.get("FROID_LOOP_ENGINE_READY_TIMEOUT", "600"))
74
+ except ValueError:
75
+ return 600.0
76
+
77
+
78
+ def _grace() -> float:
79
+ """Seconds to wait before the first probe. -1/unset → per-mode auto default."""
80
+ raw = os.environ.get("FROID_LOOP_ENGINE_READY_GRACE", "-1").strip()
81
+ try:
82
+ val = float(raw)
83
+ except ValueError:
84
+ val = -1.0
85
+ if val >= 0:
86
+ return val
87
+ mode = (os.environ.get("FROID_LOOP_ENGINE_EDITOR_MODE") or "shared").strip().lower()
88
+ return _AUTO_GRACE.get(mode, 0.0)
89
+
90
+
91
+ # run-tool prints these even on a "successful" (rc 0) invocation when the call
92
+ # actually failed — the CLI returns 0 on a connection-refused, and a tool that
93
+ # returns null comes back as an HTTP 500. Treat any of these as not-ready.
94
+ _TOOL_ERROR_MARKERS = ("error", "not found", "refused", "internal server error", "is null")
95
+
96
+
97
+ def _ready_tool() -> str:
98
+ """The optional read-only run-tool used to confirm the Editor accepts tool
99
+ calls. Empty (the default) disables the round-trip: wait-for-ready alone gates
100
+ readiness, which is sound now that the Editor hosts its own MCP server."""
101
+ return os.environ.get("FROID_LOOP_UNITY_READY_TOOL", "").strip()
102
+
103
+
104
+ def _wait_for_ready(cli: str, remaining: float) -> tuple[int, str]:
105
+ """One `wait-for-ready` call that polls for the rest of the budget. Returns
106
+ (rc, output); rc 0 means the Editor↔server bridge is up."""
107
+ cli_timeout_ms = max(1000, int(remaining * 1000))
108
+ try:
109
+ proc = subprocess.run(
110
+ [cli, "wait-for-ready", _project(), "--timeout", str(cli_timeout_ms)],
111
+ timeout=remaining + 5,
112
+ capture_output=True,
113
+ text=True,
114
+ )
115
+ except subprocess.TimeoutExpired:
116
+ return 1, "wait-for-ready process timed out"
117
+ return proc.returncode, (proc.stdout + proc.stderr).strip()[-500:]
118
+
119
+
120
+ def _run_tool_probe(cli: str, tool: str, remaining: float) -> tuple[int, str]:
121
+ """A real read-only tool round-trip against the Editor over HTTP — proof it
122
+ can accept tool calls, independent of any MCP client. Returns (rc, output)."""
123
+ tool_timeout_ms = max(1000, min(60000, int(remaining * 1000)))
124
+ try:
125
+ proc = subprocess.run(
126
+ [cli, "run-tool", tool, _project(), "--timeout", str(tool_timeout_ms)],
127
+ timeout=remaining + 5,
128
+ capture_output=True,
129
+ text=True,
130
+ )
131
+ except subprocess.TimeoutExpired:
132
+ return 1, f"run-tool {tool} timed out"
133
+ out = (proc.stdout + proc.stderr).strip()
134
+ rc = proc.returncode
135
+ # the CLI's exit code is unreliable (0 on connection-refused; non-zero on a
136
+ # null-returning but healthy tool) — also scan the output for error markers.
137
+ if rc == 0 and any(m in out.lower() for m in _TOOL_ERROR_MARKERS):
138
+ rc = 1
139
+ return rc, out[-500:]
140
+
141
+
142
+ def _ready_ivanmurzak(deadline: float) -> int:
143
+ cli = os.environ.get("UNITY_MCP_CLI", "unity-mcp-cli")
144
+ if shutil.which(cli) is None:
145
+ print(
146
+ f"unity_ready: {cli!r} not found on PATH; install the Unity-MCP CLI, set "
147
+ "UNITY_MCP_CLI, or override engine.ready_cmd",
148
+ file=sys.stderr,
149
+ )
150
+ return 2
151
+ tool = _ready_tool()
152
+ last = ""
153
+ while True:
154
+ remaining = deadline - time.monotonic()
155
+ if remaining <= 0:
156
+ break
157
+ # phase 1: wait until the Editor↔server bridge reports ready. If it
158
+ # fast-fails (Editor not listening yet) we retry until the deadline.
159
+ rc, last = _wait_for_ready(cli, remaining)
160
+ if rc != 0:
161
+ if deadline - time.monotonic() <= 0:
162
+ break
163
+ time.sleep(3) # brief pause before retrying a fast-fail
164
+ continue
165
+ # phase 2: confirm with a real tool round-trip (proves the Editor accepts
166
+ # tool calls with no client attached). Skipped when the tool is empty.
167
+ if not tool:
168
+ return 0
169
+ remaining = deadline - time.monotonic()
170
+ if remaining <= 0:
171
+ break
172
+ rc, last = _run_tool_probe(cli, tool, remaining)
173
+ if rc == 0:
174
+ return 0
175
+ if deadline - time.monotonic() <= 0:
176
+ break
177
+ time.sleep(3) # bridge up but tool not answering yet (still importing?)
178
+ print(f"unity_ready: Editor not ready within budget: {last}", file=sys.stderr)
179
+ return 1
180
+
181
+
182
+ def _ready_coplaydev(deadline: float) -> int:
183
+ url = os.environ.get("UNITY_MCP_URL", "http://localhost:8080")
184
+ parsed = urlparse(url)
185
+ host = parsed.hostname or "localhost"
186
+ port = parsed.port or (443 if parsed.scheme == "https" else 8080)
187
+ last = ""
188
+ while time.monotonic() < deadline:
189
+ try:
190
+ with socket.create_connection((host, port), timeout=5):
191
+ print(
192
+ f"unity_ready: connected to CoplayDev MCP at {host}:{port} "
193
+ "(connectivity check only — not a full Editor-ready probe)",
194
+ file=sys.stderr,
195
+ )
196
+ return 0
197
+ except OSError as exc: # server not up yet
198
+ last = str(exc)
199
+ time.sleep(2)
200
+ print(
201
+ f"unity_ready: could not reach CoplayDev MCP at {host}:{port}: {last}",
202
+ file=sys.stderr,
203
+ )
204
+ return 1
205
+
206
+
207
+ def main() -> int:
208
+ mcp = (os.environ.get("FROID_LOOP_ENGINE_MCP") or "ivanmurzak").strip().lower()
209
+ # the whole gate must finish a hair before the engine's outer timeout fires.
210
+ deadline = time.monotonic() + max(1.0, _timeout() - _SAFETY_MARGIN)
211
+ grace = min(_grace(), max(0.0, deadline - time.monotonic()))
212
+ if grace > 0:
213
+ print(
214
+ f"unity_ready: waiting {grace:.0f}s for the Editor to start before probing",
215
+ file=sys.stderr,
216
+ )
217
+ time.sleep(grace)
218
+ if mcp == "ivanmurzak":
219
+ return _ready_ivanmurzak(deadline)
220
+ if mcp == "coplaydev":
221
+ return _ready_coplaydev(deadline)
222
+ print(
223
+ f"unity_ready: unknown FROID_LOOP_ENGINE_MCP={mcp!r} (expected ivanmurzak|coplaydev)",
224
+ file=sys.stderr,
225
+ )
226
+ return 2
227
+
228
+
229
+ if __name__ == "__main__":
230
+ raise SystemExit(main())
@@ -0,0 +1,298 @@
1
+ #!/usr/bin/env python3
2
+ """Seed the Unity scene auto-save guard into a froid-loop-driven project.
3
+
4
+ Unity-MCP GameObject tools mark a scene dirty but never save it, so a project
5
+ driven by the shared editor accumulates a chronically dirty scene. That dirty
6
+ state is what raises the two run-stalling modal dialogs ("scene changed on disk —
7
+ reload?" when git/an agent rewrites the open .unity file, and "save changes before
8
+ quitting?" on editor exit). A modal freezes ``EditorApplication.update`` — which
9
+ the MCP plugin uses to dispatch tool calls — so every MCP call then times out.
10
+
11
+ This helper copies an editor-only auto-save guard (``SceneAutoSaveGuard.cs`` + its
12
+ asmdef, with pre-generated ``.meta`` files carrying fixed GUIDs) into the project's
13
+ ``Assets`` tree so the editor's very first import already sees it. It is invoked by
14
+ the Unity plugin *before* ``unity_setup.py`` launches the per_worktree Editor, and
15
+ at the readiness gate in shared mode (where ``unity_setup.py`` never runs).
16
+
17
+ The install is idempotent and version-aware: it copies the payload only when the
18
+ target ``.cs`` is absent or its ``froid-loop-scene-guard-version`` header is older
19
+ than the payload's. It never deletes or rewrites any file it did not ship, and a
20
+ missing asset tree is a graceful skip (not every worktree is a Unity project yet).
21
+
22
+ The seeded guard is committed into the consumer project by story-finalize's
23
+ ``git add -A`` — that is intended: the guard travels with the repo so any editor
24
+ that opens the project is protected. Because seeding happens pre-baseline (before
25
+ the unit's untracked-file baseline is snapshotted), ``verify.safe_rollback`` never
26
+ treats it as a created-this-unit file and never deletes it.
27
+
28
+ Env (injected by the Unity plugin):
29
+ FROID_LOOP_WORKTREE project root (Assets = <worktree>/Assets)
30
+ FROID_LOOP_UNITY_INSTALL_SCENE_GUARD "1" (default) enables; "0"/false skips
31
+ FROID_LOOP_UNITY_SCENE_GUARD_DIR install dir (default Assets/FroidLoop/Editor)
32
+
33
+ Exit 0 = seeded, already-current, or a benign skip (disabled / no asset tree);
34
+ non-zero = a real error (no worktree, unreadable payload, a failed copy).
35
+ """
36
+
37
+ from __future__ import annotations
38
+
39
+ import os
40
+ import re
41
+ import shutil
42
+ import sys
43
+ from pathlib import Path, PurePosixPath, PureWindowsPath
44
+
45
+ # The guard's version header line, e.g. "// froid-loop-scene-guard-version: 1.0.0".
46
+ # The payload's value is the source of truth; a target with an older (or missing)
47
+ # value is reinstalled, a newer/equal one is left alone.
48
+ _VERSION_RE = re.compile(r"froid-loop-scene-guard-version:\s*([0-9]+(?:\.[0-9]+)*)")
49
+ # The guard's canonical source file — its header carries the version, and its
50
+ # presence/absence in the target decides a fresh install.
51
+ _GUARD_CS = "SceneAutoSaveGuard.cs"
52
+ # Payload subdir holding the parent-folder ``.meta`` files, keyed by folder name;
53
+ # separated from the content files so "every file in the payload root" is exactly
54
+ # the set copied into the install dir.
55
+ _FOLDERS_SUBDIR = "_folders"
56
+ _DEFAULT_GUARD_DIR = "Assets/FroidLoop/Editor"
57
+
58
+
59
+ def _is_absolute(value: str) -> bool:
60
+ """True if ``value`` is rooted or drive-qualified in *either* POSIX or Windows
61
+ terms (mirrors ``froid_loop.platform_util.is_absolute_path`` — this deployed
62
+ script is stdlib-only and cannot import core)."""
63
+ win = PureWindowsPath(value)
64
+ return PurePosixPath(value).is_absolute() or bool(win.drive or win.root)
65
+
66
+
67
+ def _has_parent_ref(value: str) -> bool:
68
+ """True if ``value`` contains a ``..`` segment in *either* POSIX or Windows
69
+ terms (mirrors ``froid_loop.platform_util.has_parent_ref``)."""
70
+ return ".." in PurePosixPath(value).parts or ".." in PureWindowsPath(value).parts
71
+
72
+
73
+ def _names_tree_root(value: str) -> bool:
74
+ """True if ``value`` names the worktree rather than anything inside it
75
+ (mirrors ``froid_loop.platform_util.names_tree_root``).
76
+
77
+ The third member of the family, and the one this script was missing. Win32
78
+ strips every trailing period and space from a path's final component, so
79
+ ``"..."`` names the worktree root there while both pure flavours read it as an
80
+ ordinary one-segment name. That mattered here: the asset-root probe below
81
+ would find the worktree itself for such a value, so the payload landed in the
82
+ worktree root instead of under ``Assets/``. (The caller once ``.strip()``-ed
83
+ the env var before validating, which collapsed the *space* spellings into
84
+ ``"."``; validation now sees the authored value, so every spelling lands
85
+ here.)"""
86
+ if PurePosixPath(value) == PurePosixPath(".") or PureWindowsPath(value) == PureWindowsPath("."):
87
+ return True
88
+ parts = [part for part in value.replace("\\", "/").split("/") if part]
89
+ return bool(parts) and all(part.strip(" .") == "" and part != ".." for part in parts)
90
+
91
+
92
+ # Reserved on Windows regardless of extension: CON.txt is as illegal as CON. Mirrors
93
+ # ``froid_loop.platform_util._RESERVED_BASENAMES`` member for member, including its
94
+ # deliberate over-refusals: Microsoft's published list names only COM1-COM9 /
95
+ # LPT1-LPT9 and omits the console pair, while Wine's ``RtlIsDosDeviceName_U`` matches
96
+ # CONIN$/CONOUT$ but rejects the 0 forms, so COM0/LPT0 are backed by neither. Mirror
97
+ # the set, not any claim about it.
98
+ _RESERVED_BASENAMES = frozenset(
99
+ {"CON", "PRN", "AUX", "NUL", "CONIN$", "CONOUT$"}
100
+ | {f"COM{i}" for i in range(10)}
101
+ | {f"LPT{i}" for i in range(10)}
102
+ | {f"COM{s}" for s in "¹²³"}
103
+ | {f"LPT{s}" for s in "¹²³"}
104
+ )
105
+
106
+
107
+ def _is_reserved_basename(seg: str) -> bool:
108
+ """True if ``seg``'s basename (before the first dot, trailing spaces trimmed —
109
+ ``CON .txt`` counts) is a Windows reserved device name (mirrors
110
+ ``froid_loop.platform_util._is_reserved_basename``)."""
111
+ stem = seg.split(".", 1)[0].rstrip(" ")
112
+ return stem.upper() in _RESERVED_BASENAMES
113
+
114
+
115
+ def _names_win32_alias(value: str) -> bool:
116
+ """True if any component of ``value`` names something other than itself on Win32 —
117
+ a reserved device name, or a name whose trailing periods and spaces Win32 trims
118
+ away before the path ever reaches the filesystem (mirrors
119
+ ``froid_loop.platform_util.names_win32_alias`` — this deployed script is stdlib-only
120
+ and cannot import core).
121
+
122
+ The fourth member of the family, and the only one about *determinism* rather than
123
+ containment: ``"Assets/NUL"`` and ``"Assets/FroidLoop/Editor."`` are both inside the
124
+ worktree by every measure the other three apply, and both name a different thing on
125
+ Windows than they spell here. The ``not root_naming`` term hands a value made
126
+ entirely of period/space components back to :func:`_names_tree_root` — while still
127
+ catching one such component embedded beside a real one (``"Assets/..."``), which is
128
+ nobody's root and nobody's parent — and the ``part not in (".", "..")`` carve-out
129
+ hands plain ``".."`` back to :func:`_has_parent_ref`, so all four refuse disjoint
130
+ spelling classes. Core's docstring carries the two rules, their sources, and the
131
+ Windows 11 narrowing this deliberately does not track."""
132
+ parts = [part for part in value.replace("\\", "/").split("/") if part]
133
+ root_naming = _names_tree_root(value)
134
+ return any(
135
+ _is_reserved_basename(part)
136
+ or (part != part.rstrip(" .") and part not in (".", "..") and not root_naming)
137
+ for part in parts
138
+ )
139
+
140
+
141
+ def _truthy(value: str | None, default: bool) -> bool:
142
+ if value is None or value.strip() == "":
143
+ return default
144
+ return value.strip().lower() in ("1", "true", "yes", "on")
145
+
146
+
147
+ def _worktree() -> Path | None:
148
+ wt = os.environ.get("FROID_LOOP_WORKTREE")
149
+ return Path(wt) if wt else None
150
+
151
+
152
+ def _payload_dir() -> Path:
153
+ """The bundled ``unity_assets/`` payload, resolved relative to this script so it
154
+ travels with a project-local copy of the plugin too."""
155
+ return Path(__file__).resolve().parent / "unity_assets"
156
+
157
+
158
+ def _parse_version(text: str) -> tuple[int, ...] | None:
159
+ """The ``froid-loop-scene-guard-version`` tuple from a guard source, or None if
160
+ the header is absent (an absent/unrecognized header sorts as oldest)."""
161
+ match = _VERSION_RE.search(text)
162
+ if not match:
163
+ return None
164
+ return tuple(int(part) for part in match.group(1).split("."))
165
+
166
+
167
+ def _read_version(cs_path: Path) -> tuple[int, ...] | None:
168
+ try:
169
+ return _parse_version(cs_path.read_text(encoding="utf-8", errors="replace"))
170
+ except OSError:
171
+ return None
172
+
173
+
174
+ def _content_files(payload: Path) -> list[Path]:
175
+ """The payload files copied verbatim into the install dir: the guard source,
176
+ the asmdef, and their ``.meta`` companions (everything directly in the payload
177
+ root — the ``_folders`` subdir is handled separately)."""
178
+ return sorted(p for p in payload.iterdir() if p.is_file())
179
+
180
+
181
+ def _ensure_dir_with_meta(directory: Path, payload: Path) -> None:
182
+ """Create ``directory`` if absent and, when the payload ships a matching
183
+ folder ``.meta`` (keyed by the folder's name), drop it beside the folder with
184
+ its fixed GUID. Never clobbers an existing folder meta — its GUID may already
185
+ be referenced by the project."""
186
+ directory.mkdir(parents=True, exist_ok=True)
187
+ folder_meta = payload / _FOLDERS_SUBDIR / f"{directory.name}.meta"
188
+ target_meta = directory.parent / f"{directory.name}.meta"
189
+ if folder_meta.is_file() and not target_meta.exists():
190
+ shutil.copy2(folder_meta, target_meta)
191
+
192
+
193
+ def _install(worktree: Path, target_dir: Path, payload: Path) -> int:
194
+ """Copy the payload into ``target_dir``, creating each parent folder (with its
195
+ fixed-GUID meta) along the way. Returns 0 on success, non-zero on a real I/O
196
+ error."""
197
+ rel = target_dir.relative_to(worktree)
198
+ try:
199
+ # Create every path segment from the worktree down to the install dir,
200
+ # laying a fixed-GUID folder meta beside each that the payload ships one for
201
+ # (Assets itself has no payload meta — Unity owns it — so it is skipped).
202
+ current = worktree
203
+ for segment in rel.parts:
204
+ current = current / segment
205
+ _ensure_dir_with_meta(current, payload)
206
+ for src in _content_files(payload):
207
+ shutil.copy2(src, target_dir / src.name)
208
+ except OSError as exc:
209
+ print(f"unity_seed_assets: install failed: {exc}", file=sys.stderr)
210
+ return 1
211
+ print(
212
+ f"unity_seed_assets: seeded scene auto-save guard into {rel}",
213
+ file=sys.stderr,
214
+ )
215
+ return 0
216
+
217
+
218
+ def main() -> int:
219
+ if not _truthy(os.environ.get("FROID_LOOP_UNITY_INSTALL_SCENE_GUARD"), True):
220
+ print("unity_seed_assets: scene guard disabled; skipping", file=sys.stderr)
221
+ return 0
222
+
223
+ worktree = _worktree()
224
+ if worktree is None:
225
+ print("unity_seed_assets: FROID_LOOP_WORKTREE is not set", file=sys.stderr)
226
+ return 2
227
+
228
+ payload = _payload_dir()
229
+ guard_src = payload / _GUARD_CS
230
+ if not guard_src.is_file():
231
+ print(f"unity_seed_assets: payload guard missing at {guard_src}", file=sys.stderr)
232
+ return 2
233
+ payload_version = _read_version(guard_src)
234
+
235
+ # `.strip()` decides only whether the env var is SET — the authored value is
236
+ # what gets validated. Stripping before validation silently trimmed the exact
237
+ # spelling the guard below promises to refuse ("Assets/FroidLoop/Editor " was
238
+ # trimmed and installed into Editor), and made this the one site of seven whose
239
+ # config value was normalized before the family saw it.
240
+ raw = os.environ.get("FROID_LOOP_UNITY_SCENE_GUARD_DIR", "")
241
+ guard_dir = raw if raw.strip() else _DEFAULT_GUARD_DIR
242
+ rel = Path(guard_dir)
243
+ # The install dir must stay inside the worktree AND name something in it: an
244
+ # absolute/drive-qualified path would make _install's relative_to() raise, a
245
+ # ".." segment would let the copy escape the project tree, and a root-naming
246
+ # spelling would scatter the payload across the worktree root itself. The fourth
247
+ # term is about determinism rather than containment: Win32 trims a component's
248
+ # trailing periods and spaces, so "Assets/FroidLoop/Editor." installs into "Editor"
249
+ # while the configured string still spells "Editor.", and a component naming a
250
+ # reserved device writes to the device instead of the tree.
251
+ if (
252
+ not rel.parts
253
+ or _names_tree_root(guard_dir)
254
+ or _is_absolute(guard_dir)
255
+ or _has_parent_ref(guard_dir)
256
+ or _names_win32_alias(guard_dir)
257
+ ):
258
+ print(
259
+ f"unity_seed_assets: invalid scene guard dir {guard_dir!r}: it must name a "
260
+ "path inside the worktree and must not name a Windows device or end a "
261
+ "component in a period or space",
262
+ file=sys.stderr,
263
+ )
264
+ return 2
265
+ target_dir = worktree / guard_dir
266
+
267
+ # Only seed into a project whose asset root is actually checked out. A worktree
268
+ # without it is not (yet) a Unity project, and scattering an Assets/ tree into
269
+ # it would be wrong — a benign skip, never an error, never destructive.
270
+ asset_root = worktree / rel.parts[0]
271
+ if not asset_root.is_dir():
272
+ print(
273
+ f"unity_seed_assets: {rel.parts[0]}/ not present under the worktree; "
274
+ "nothing to seed",
275
+ file=sys.stderr,
276
+ )
277
+ return 0
278
+
279
+ target_cs = target_dir / _GUARD_CS
280
+ if target_cs.is_file():
281
+ target_version = _read_version(target_cs)
282
+ # An unreadable/absent header sorts as oldest, so a foreign or stale guard
283
+ # is refreshed; an equal-or-newer guard is left untouched (idempotent).
284
+ current = target_version or ()
285
+ incoming = payload_version or ()
286
+ if current >= incoming:
287
+ print(
288
+ "unity_seed_assets: scene guard already current "
289
+ f"({'.'.join(map(str, current)) or 'unversioned'}); nothing to do",
290
+ file=sys.stderr,
291
+ )
292
+ return 0
293
+
294
+ return _install(worktree, target_dir, payload)
295
+
296
+
297
+ if __name__ == "__main__":
298
+ raise SystemExit(main())