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