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
froid_loop/fences.py ADDED
@@ -0,0 +1,103 @@
1
+ """Whether a markdown offset sits inside a fenced code block.
2
+
3
+ A leaf module with no froid-loop imports, deliberately: the two readers that need
4
+ this — `devcontract` (is a `## Auto Run Result` heading real, or quoted?) and
5
+ `deferredwork` (is a `gate:` line a declaration, or an example?) — sit on opposite
6
+ sides of an import edge (`devcontract` imports `deferredwork`), so neither can
7
+ host it for the other. `devcontract._section_headings` already argued the case in
8
+ prose: "a second copy of `_fenced`'s open-marker walk is exactly the kind of
9
+ near-duplicate that drifts." This module is that argument taken one step further
10
+ once a second subsystem needed the same walk.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import re
16
+
17
+ # A fence line: up to three spaces of indent, then a maximal run of >= 3 backticks
18
+ # or tildes (its char AND length both matter per CommonMark), then the rest of the
19
+ # line — an info string on an opener; on a close, only whitespace is allowed.
20
+ FENCE_LINE_RE = re.compile(r"^ {0,3}(`{3,}|~{3,})([^\n]*)$", re.MULTILINE)
21
+
22
+
23
+ def _delimits(marker: str, rest: str) -> bool:
24
+ """Whether a matched line delimits a fence at all, or is ordinary text.
25
+
26
+ CommonMark forbids a backtick anywhere in the info string of a BACKTICK fence,
27
+ and only there — the rule exists so that inline code is not read as opening a
28
+ block. Tilde fences carry no such restriction. Checked here rather than folded
29
+ into `FENCE_LINE_RE` so the pattern stays one readable alternation instead of
30
+ two near-identical arms with different info-string classes.
31
+
32
+ The miss runs the wrong way for `deferredwork`: a line of prose quoting a
33
+ fence opens a block CommonMark never opens, and a real `gate:` above the next
34
+ closing run is then read as an example and silently stops gating — the lost
35
+ gate the field exists to prevent, not the spurious refusal it tolerates.
36
+ """
37
+ return marker[0] == "~" or "`" not in rest
38
+
39
+
40
+ def fenced(text: str, offset: int, *, unclosed_hides_rest: bool = True) -> bool:
41
+ """True when ``offset`` falls inside a ``` / ~~~ fenced code block.
42
+
43
+ A fence opens on a line of three-or-more backticks or tildes (indentable up
44
+ to three spaces; a tab would make an indented code block instead). Per
45
+ CommonMark it closes only on a later line using the SAME character, at least
46
+ as long as the opener, with no trailing non-whitespace — so a shorter run, a
47
+ different fence char, or an info-bearing line inside the block is content,
48
+ not a close. Tracking the open fence's char+length (not a bare line-parity
49
+ count) is what stops a nested-or-mismatched inner fence from flipping state
50
+ early and exposing a quoted heading as a real one.
51
+
52
+ ``unclosed_hides_rest`` decides the one case CommonMark leaves to the reader:
53
+ a fence that opens and never closes. The two callers need opposite answers,
54
+ and both are choosing the direction where being wrong is survivable, so this
55
+ is a parameter rather than a policy:
56
+
57
+ - ``True`` (``devcontract``): everything after the opener is content. Reading
58
+ a quoted ``## Auto Run Result`` as a real section is a *destructive* misread
59
+ — it strips or terminates a spec — so an ambiguous tail must stay inert.
60
+ - ``False`` (``deferredwork``): the opener is ordinary text. A `gate:` line
61
+ below a stray fence must keep gating, because a gate lost in silence is the
62
+ exact failure that field exists to end; a spurious refusal in an entry whose
63
+ markdown is already malformed is the cheaper wrong answer.
64
+ """
65
+ return any(
66
+ s <= offset < e for s, e in fenced_spans(text, unclosed_hides_rest=unclosed_hides_rest)
67
+ )
68
+
69
+
70
+ def fenced_spans(text: str, *, unclosed_hides_rest: bool = True) -> list[tuple[int, int]]:
71
+ """Half-open ``[start, end)`` ranges of ``text`` that sit inside a fenced block.
72
+
73
+ The walk itself, which `fenced()` reduces to one offset and
74
+ `deferredwork.parse_legacy` blanks out wholesale before scanning line by line.
75
+ Keeping it here is the point of the module: a reader that needs the ranges and
76
+ a reader that needs one answer must not disagree about where a block ends.
77
+
78
+ The bounds follow the delimiters' roles rather than their extents. A span opens
79
+ one character past the opener's line start, so the opener itself reads as
80
+ outside the block — it is markup that a scanner may still want to see. It ends
81
+ one character past the closer's line start, which puts the closer *inside*: the
82
+ scanners this serves anchor at column 0, and a closing delimiter is the one
83
+ line of a block that can never be mistaken for the content it terminates.
84
+ """
85
+ spans: list[tuple[int, int]] = []
86
+ open_marker: str | None = None
87
+ start = 0
88
+ for m in FENCE_LINE_RE.finditer(text):
89
+ marker, rest = m.group(1), m.group(2)
90
+ if not _delimits(marker, rest):
91
+ continue # inline code, not a fence line
92
+ if open_marker is None:
93
+ open_marker, start = marker, m.start() + 1 # opener — an info string is allowed
94
+ elif marker[0] == open_marker[0] and len(marker) >= len(open_marker) and not rest.strip():
95
+ spans.append((start, m.start() + 1)) # valid closing fence
96
+ open_marker = None
97
+ # else: a shorter / mismatched / info-bearing fence line — literal content
98
+ if open_marker is not None and unclosed_hides_rest:
99
+ # Past the last offset, not up to it: an unclosed fence has no end, and
100
+ # `fenced()` answered True for an offset at or beyond `len(text)` before
101
+ # these ranges existed. Slicing clamps, so a mask is unaffected either way.
102
+ spans.append((start, len(text) + 1))
103
+ return spans
@@ -0,0 +1,226 @@
1
+ """Resolve FROID artifact paths from _froid/bmm/config.yaml."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from pathlib import Path
7
+
8
+ import yaml
9
+
10
+ from .platform_util import resolve_or_lexical
11
+
12
+
13
+ class FroidConfigError(Exception):
14
+ pass
15
+
16
+
17
+ @dataclass(frozen=True)
18
+ class ProjectPaths:
19
+ project: Path
20
+ implementation_artifacts: Path
21
+ planning_artifacts: Path
22
+ # the FROID output root (parent of the artifact dirs, holds project-context,
23
+ # test-artifacts, story-froid_loop, …). Protected wholesale on rollback so a
24
+ # failed attempt never deletes generated FROID output. Defaults to
25
+ # {project-root}/_froid-output when the config omits `output_folder`.
26
+ output_folder: Path = field(default=None) # type: ignore[assignment]
27
+ # the git root code/git work happens against; defaults to `project`. Phase 1
28
+ # foundation for worktree isolation — see ProjectPaths.rebased and Workspace.
29
+ repo_root: Path = field(default=None) # type: ignore[assignment]
30
+
31
+ def __post_init__(self) -> None:
32
+ if self.output_folder is None:
33
+ object.__setattr__(self, "output_folder", (self.project / "_froid-output").resolve())
34
+ if self.repo_root is None:
35
+ object.__setattr__(self, "repo_root", self.project)
36
+
37
+ @property
38
+ def sprint_status(self) -> Path:
39
+ return self.implementation_artifacts / "sprint-status.yaml"
40
+
41
+ @property
42
+ def deferred_work(self) -> Path:
43
+ return self.implementation_artifacts / "deferred-work.md"
44
+
45
+ def rebased(self, new_root: Path) -> ProjectPaths:
46
+ """Re-resolve the project and its artifact dirs onto `new_root` (a full
47
+ checkout, e.g. a git worktree). Artifact dirs configured outside the
48
+ project tree are shared, not per-checkout, so they don't move. The new
49
+ ProjectPaths is rooted at `new_root` for both `project` and `repo_root`."""
50
+ new_root = new_root.resolve()
51
+
52
+ def rebase(p: Path) -> Path:
53
+ try:
54
+ rel = p.relative_to(self.project)
55
+ except ValueError:
56
+ return p # configured outside the project tree; doesn't move
57
+ return (new_root / rel).resolve()
58
+
59
+ return ProjectPaths(
60
+ project=new_root,
61
+ implementation_artifacts=rebase(self.implementation_artifacts),
62
+ planning_artifacts=rebase(self.planning_artifacts),
63
+ output_folder=rebase(self.output_folder),
64
+ repo_root=new_root,
65
+ )
66
+
67
+
68
+ def worktree_isolation_conflict(paths: ProjectPaths, isolation: str) -> str | None:
69
+ """The refusal message for ``isolation = "worktree"`` under a `repo_root`
70
+ override, or None when the combination is supported (#414).
71
+
72
+ Worktree provisioning reads ``repo_root`` for every surface it seeds *off disk*
73
+ — the upstream skill trees, `_froid/` and the `_froid/custom/` overrides inside
74
+ it, and each `seed_files`/`seed_globs` entry — and bakes the absolute hook-relay
75
+ path from it into the worktree's hook config, while `init`, `validate` and the
76
+ run preflight write and probe those same surfaces under ``project``. (The relay
77
+ itself is pointed at, never copied. The `MODULE_SKILLS` this wheel bundles are
78
+ seeded from package data and are unaffected by either root; nothing is seeded
79
+ from ``project``, which `provision_worktree` is never even passed.)
80
+ `load_paths` *requires* `project/_froid/bmm/config.yaml`, so `_froid/` is under
81
+ `project` by definition and `repo_root/_froid/` generally does not exist. When
82
+ the two diverge the preflight therefore approves a surface the isolated run
83
+ never receives, and the seed-completeness gates go inert rather than fire: an
84
+ isolated session dispatches into a worktree with no dev primitive and no
85
+ renderer, and stops with no result and nothing journaled naming the cause.
86
+
87
+ **This function exists to be deleted.** The real fix is #443 — plumb ``project``
88
+ through provisioning for the non-git reads — and landing it removes this
89
+ function, all five of its call sites, the `policy.isolation-repo-root` id and
90
+ both doc sentences. It is a refusal rather than the fix because "which root
91
+ wins" is a separate decision per seeded surface (the relay only exists under
92
+ `project`; operator-configured `seed_files` may legitimately name a path outside
93
+ it), and `ProjectPaths.rebased` encodes `project == repo_root` besides. So the
94
+ message names only remediations that exist today. Both are named because either
95
+ alone is sufficient and which one is right is the operator's call: the override
96
+ buys a decoupled git root, the isolation mode buys per-unit worktrees, and until
97
+ #443 lands the orchestrator cannot give both.
98
+
99
+ Sole producer of the text, shared by `cmd_validate`, the run/sweep preflight,
100
+ the dry-run honesty banner and the TUI's pre-launch guard, so the four cannot
101
+ drift. Compares resolved paths: `load_paths` resolves both sides, but a
102
+ hand-built :class:`ProjectPaths` (tests) need not have."""
103
+ if isolation != "worktree":
104
+ return None
105
+ # The default config — no `repo_root` key, so `__post_init__` makes the two the
106
+ # same object — is settled here, before anything asks the OS. That is not just an
107
+ # optimization: the two calls below are independent, so a *transient*
108
+ # canonicalization failure between them (the guard catches every OSError, not only
109
+ # a persistent WinError 64) could have one side degrade to lexical while the other
110
+ # succeeds and canonicalizes, making one path unequal to itself and refusing an
111
+ # ordinary isolated run with the #414 text. Comparing raw first means the common
112
+ # shape cannot reach that window at all.
113
+ if paths.repo_root == paths.project:
114
+ return None
115
+ # Degrades rather than raises (#552): this gate runs in `cmd_validate` *before*
116
+ # the platform preflight, so a raise here is the #332 finding going unreachable
117
+ # again for anyone on `isolation = "worktree"`. A ProjectPaths built by
118
+ # `load_paths` arrives with both sides canonical (it raises otherwise), so the
119
+ # degrade below covers only hand-built instances and a share flapping between
120
+ # the load and this gate. Both sides take the same treatment, so a host that
121
+ # cannot canonicalize compares lexical to lexical; the cost, stated rather than
122
+ # hidden: two spellings that only canonicalization folds together (`p/../p` vs
123
+ # `p`) would be refused with a wrong message, where the alternative is no
124
+ # message and no command at all.
125
+ if resolve_or_lexical(paths.repo_root) == resolve_or_lexical(paths.project):
126
+ return None
127
+ return (
128
+ 'isolation = "worktree" is not supported when repo_root differs from the project '
129
+ f"directory: worktree provisioning seeds from repo_root ({paths.repo_root}) while "
130
+ f"init, validate and the run preflight read the project ({paths.project}), so an "
131
+ "isolated session would get none of the skills the preflight just approved. "
132
+ "Remove the `repo_root` key from _froid/bmm/config.yaml, or set "
133
+ '`isolation = "none"` under [scm] in .froid-loop/policy.toml.'
134
+ )
135
+
136
+
137
+ def _canonical(expanded: Path, label: str) -> Path:
138
+ """Canonicalize-or-raise, the shared boundary for every ProjectPaths member.
139
+ `label` names what refused in the operator's terms — a configured string is
140
+ reported with its raw spelling, the default output folder as the default —
141
+ so the message never calls a path "configured" that nobody configured."""
142
+ try:
143
+ return expanded.resolve()
144
+ except (OSError, RuntimeError) as e:
145
+ raise FroidConfigError(
146
+ f"cannot canonicalize the {label} ({expanded}): {e} — "
147
+ "whether it lies inside or outside the project tree cannot be determined, "
148
+ "so no run can safely proceed. Run `froid-loop validate` for what this "
149
+ "host is doing."
150
+ ) from e
151
+
152
+
153
+ def _resolve(raw: str, project: Path) -> Path:
154
+ """Expand `{project-root}` and canonicalize, or raise typed. A config string can
155
+ name a UNC share of its own, independent of `--project`, so it refuses on the same
156
+ terms as the root in `load_paths` (#552). Degrading to the lexical spelling was
157
+ tried and retired: a spelling the OS cannot canonicalize has an *unknowable*
158
+ location — it can sit lexically inside the project while an in-tree symlink or
159
+ junction carries it to a dead share outside — so any in-tree/external answer
160
+ `rebased`'s `relative_to` reads off the spelling is a guess, and a wrong guess
161
+ sends a worktree-isolated run's artifact writes into a worktree-local directory
162
+ instead of the configured destination. No member enters a snapshot unresolved."""
163
+ expanded = Path(raw.replace("{project-root}", str(project)))
164
+ return _canonical(expanded, f"configured path {raw!r}")
165
+
166
+
167
+ def load_paths(project: Path) -> ProjectPaths:
168
+ # The root must canonicalize or there is no consistent ProjectPaths to hand back
169
+ # (#552). Every member is compared against `project` — `rebased` decides "is this
170
+ # artifact dir inside the tree" with `relative_to` — so a lexical root next to a
171
+ # canonically spelled member (a resolved child, or an absolute path written
172
+ # canonically in config.yaml) sits on the far side of a symlink from it, files an
173
+ # in-tree artifact dir as external, and a worktree-isolated run then writes into
174
+ # the original checkout. Degrading here reopened that split once per review round;
175
+ # a typed raise closes every route at once, and it costs the diagnostic commands
176
+ # nothing: every caller already catches FroidConfigError — `cmd_validate` records
177
+ # the failure and still reaches the platform preflight that names the host — and
178
+ # `diagnose` never loads paths at all. Only `cli._project` still degrades: it runs
179
+ # pre-dispatch, where there is no handler to catch anything.
180
+ try:
181
+ project = Path(project).resolve()
182
+ except (OSError, RuntimeError) as e:
183
+ raise FroidConfigError(
184
+ f"cannot canonicalize the project root {project}: {e} — artifact paths "
185
+ "are derived from the canonical root, so no run can safely proceed. "
186
+ "Run `froid-loop validate` for what this host is doing."
187
+ ) from e
188
+ config_path = project / "_froid" / "bmm" / "config.yaml"
189
+ if not config_path.is_file():
190
+ raise FroidConfigError(f"FROID config not found: {config_path} (is FROID installed here?)")
191
+ try:
192
+ # UnicodeDecodeError is a ValueError, not an OSError, so an undecodable file
193
+ # would otherwise escape every caller's `except FroidConfigError` and crash
194
+ # them. Same reasoning as `policy.load`.
195
+ raw = config_path.read_text(encoding="utf-8")
196
+ except UnicodeDecodeError as e:
197
+ raise FroidConfigError(f"{config_path} is not valid UTF-8: {e}") from e
198
+ try:
199
+ doc = yaml.safe_load(raw) or {}
200
+ except yaml.YAMLError as e:
201
+ raise FroidConfigError(f"invalid YAML in {config_path}: {e}") from e
202
+
203
+ impl = doc.get("implementation_artifacts")
204
+ plan = doc.get("planning_artifacts")
205
+ if not impl or not plan:
206
+ raise FroidConfigError(
207
+ f"{config_path} missing implementation_artifacts/planning_artifacts keys"
208
+ )
209
+ repo_root_raw = doc.get("repo_root")
210
+ repo_root = _resolve(str(repo_root_raw), project) if repo_root_raw else project
211
+ out_raw = doc.get("output_folder")
212
+ output_folder = (
213
+ _resolve(str(out_raw), project)
214
+ if out_raw
215
+ # the default branch is a bare join off the (canonical) root and takes the
216
+ # same canonicalize-or-raise treatment as a configured string: an in-tree
217
+ # junction under the default name misclassifies exactly like a configured one.
218
+ else _canonical(project / "_froid-output", "default output folder")
219
+ )
220
+ return ProjectPaths(
221
+ project=project,
222
+ implementation_artifacts=_resolve(str(impl), project),
223
+ planning_artifacts=_resolve(str(plan), project),
224
+ output_folder=output_folder,
225
+ repo_root=repo_root,
226
+ )