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/stories.py
ADDED
|
@@ -0,0 +1,615 @@
|
|
|
1
|
+
"""Model of stories.yaml — the Story Breakdown manifest for "stories mode".
|
|
2
|
+
|
|
3
|
+
The optional Story Breakdown step of `froid-spec` emits ``stories.yaml``, a
|
|
4
|
+
fixed-name sibling of ``SPEC.md`` in the spec folder (discovered by name, never
|
|
5
|
+
referenced from frontmatter). It is a flat list, one entry per story, in strict
|
|
6
|
+
execution order — **there is no ``depends_on`` field**, so the schedule is a
|
|
7
|
+
single left-to-right scan, not a DAG. Each entry pins a stable, prefix-free,
|
|
8
|
+
machine-opaque ``id`` plus ``title``/``description`` and the caller-only knobs
|
|
9
|
+
``spec_checkpoint`` / ``done_checkpoint`` / ``invoke_dev_with`` /
|
|
10
|
+
``closes_deferred``. ``status`` is deliberately absent: froid-spec is the sole
|
|
11
|
+
writer of ``stories.yaml`` and froid-build-auto is the sole writer of each story
|
|
12
|
+
spec's status — the orchestrator writes neither.
|
|
13
|
+
|
|
14
|
+
This module is the strict, typed parser the orchestrator reads it through. The
|
|
15
|
+
upstream schema (validity rule 4) already says ids are quoted strings of
|
|
16
|
+
letters/digits/dashes, but an LLM-authored file may still emit an unquoted
|
|
17
|
+
``id: 1`` (PyYAML -> int) or ``id: 3.5`` (-> float); we ``str()``-normalize then
|
|
18
|
+
charset-validate as defense-in-depth. Everything here is pure contract: no
|
|
19
|
+
engine or sprint-mode coupling (only :mod:`verify`'s frontmatter readers, for
|
|
20
|
+
the id-keyed disk resolution).
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import re
|
|
26
|
+
from dataclasses import dataclass
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
|
|
29
|
+
import yaml
|
|
30
|
+
|
|
31
|
+
from . import deferredwork
|
|
32
|
+
from .frontmatter import read_frontmatter, status_of
|
|
33
|
+
|
|
34
|
+
# Fixed-name discovery, like SPEC.md / .memlog.md — never listed in companions.
|
|
35
|
+
STORIES_FILENAME = "stories.yaml"
|
|
36
|
+
# Story specs live under <spec-folder>/stories/, keyed <id>-<slug>.md.
|
|
37
|
+
STORIES_SUBDIR = "stories"
|
|
38
|
+
|
|
39
|
+
# Schema validity rule 4: ids are letters, digits, and dashes only. Matches the
|
|
40
|
+
# upstream authoring rule exactly — ids become filename segments and task keys,
|
|
41
|
+
# so a stray character must fail loud, not slip into a path.
|
|
42
|
+
ID_RE = re.compile(r"^[A-Za-z0-9]+(-[A-Za-z0-9]+)*$")
|
|
43
|
+
|
|
44
|
+
REQUIRED_FIELDS = ("id", "title", "description")
|
|
45
|
+
|
|
46
|
+
# The dispatch-protocol read model. Non-terminal statuses a re-dispatch resumes
|
|
47
|
+
# from (the session died mid-flight); `done` is terminal-skip; `blocked` stops
|
|
48
|
+
# the run. A story spec that is absent reads as PENDING (never dispatched).
|
|
49
|
+
RESUMABLE_STATUSES = frozenset({"draft", "ready-for-dev", "in-progress", "in-review"})
|
|
50
|
+
DONE = "done"
|
|
51
|
+
BLOCKED = "blocked"
|
|
52
|
+
|
|
53
|
+
# Statuses that prove a spec_checkpoint story's plan already exists on disk: once
|
|
54
|
+
# the plan reached (or passed) the Ready-for-Development gate the halt leg is
|
|
55
|
+
# spent, so a re-dispatch is the plain implement leg. PENDING / draft (plan not
|
|
56
|
+
# yet produced) and sentinel/ambiguous (a failed pre-planning halt) fall through
|
|
57
|
+
# to a fresh halt leg. Shared by the engine (real dispatch) and the CLI dry-run
|
|
58
|
+
# so the two agree on which leg a story is on.
|
|
59
|
+
PLAN_PRODUCED_STATUSES = frozenset({"ready-for-dev", "in-progress", "in-review", "done", "blocked"})
|
|
60
|
+
|
|
61
|
+
# resolve_story_spec state kinds.
|
|
62
|
+
KIND_PENDING = "pending" # no story spec on disk yet
|
|
63
|
+
KIND_PRESENT = "present" # exactly one real story spec; carries .status
|
|
64
|
+
KIND_AMBIGUOUS = "ambiguous" # >1 matching file — an anomaly, refuse to pick
|
|
65
|
+
KIND_SENTINEL = "sentinel" # the single match is a fixed-slug skeletal sentinel
|
|
66
|
+
|
|
67
|
+
# Fixed-slug skeletal specs the skill writes on a pre-planning HALT, kept inside
|
|
68
|
+
# the <id>-*.md glob so "no file = pending" holds; recoverable by deletion.
|
|
69
|
+
SENTINEL_UNRESOLVED = "unresolved"
|
|
70
|
+
SENTINEL_AMBIGUOUS = "ambiguous"
|
|
71
|
+
SENTINEL_SLUGS = (SENTINEL_UNRESOLVED, SENTINEL_AMBIGUOUS)
|
|
72
|
+
|
|
73
|
+
# schedule() outcomes.
|
|
74
|
+
SCHEDULE_NEXT = "next" # .entry is the next story to dispatch
|
|
75
|
+
SCHEDULE_COMPLETE = "complete" # every story is done — the run is finished
|
|
76
|
+
SCHEDULE_WEDGED = "wedged" # scan stopped on a blocked/sentinel/ambiguous entry
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class StoriesError(Exception):
|
|
80
|
+
pass
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@dataclass(frozen=True)
|
|
84
|
+
class StoryEntry:
|
|
85
|
+
"""One story in the breakdown. ``id`` is stable once its spec file exists;
|
|
86
|
+
the checkpoint flags are independent (a story may set both and pause twice).
|
|
87
|
+
``invoke_dev_with`` is free text appended verbatim to the dispatch prompt —
|
|
88
|
+
the single planner->dev channel, never interpreted here.
|
|
89
|
+
|
|
90
|
+
``closes_deferred`` names the deferred-work ledger ids this story closes
|
|
91
|
+
(#234). It is a *declaration channel*, not a status: the orchestrator marks
|
|
92
|
+
those entries resolved at clean close, and the ids are checked against the
|
|
93
|
+
ledger at preflight. It lives here as well as in the story spec's
|
|
94
|
+
frontmatter because the breakdown is written while the ledger is in view,
|
|
95
|
+
whereas the spec is generated later by a skill that knows nothing of it."""
|
|
96
|
+
|
|
97
|
+
id: str
|
|
98
|
+
title: str
|
|
99
|
+
description: str
|
|
100
|
+
spec_checkpoint: bool = False
|
|
101
|
+
done_checkpoint: bool = False
|
|
102
|
+
invoke_dev_with: str = ""
|
|
103
|
+
closes_deferred: tuple[str, ...] = ()
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@dataclass(frozen=True)
|
|
107
|
+
class Stories:
|
|
108
|
+
path: Path
|
|
109
|
+
entries: tuple[StoryEntry, ...]
|
|
110
|
+
|
|
111
|
+
def get(self, story_id: str) -> StoryEntry | None:
|
|
112
|
+
sid = str(story_id).strip()
|
|
113
|
+
return next((e for e in self.entries if e.id == sid), None)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def load_stories(spec_folder: Path | str) -> Stories:
|
|
117
|
+
"""Parse + validate ``<spec-folder>/stories.yaml`` into a typed :class:`Stories`.
|
|
118
|
+
|
|
119
|
+
Validates: required fields present, ids unique and prefix-free, no ``status``
|
|
120
|
+
key, id charset. Ids are ``str()``-normalized before validation (int/float
|
|
121
|
+
coercion defense). Raises :class:`StoriesError` with the pinned
|
|
122
|
+
``no stories.yaml found`` message when the file is absent. **No DAG / cycle
|
|
123
|
+
validation** — the list is strictly linear.
|
|
124
|
+
"""
|
|
125
|
+
path = Path(spec_folder) / STORIES_FILENAME
|
|
126
|
+
if not path.is_file():
|
|
127
|
+
raise StoriesError("no stories.yaml found")
|
|
128
|
+
try:
|
|
129
|
+
raw = path.read_text(encoding="utf-8")
|
|
130
|
+
except UnicodeDecodeError as e:
|
|
131
|
+
# A binary/non-UTF-8 manifest raises UnicodeDecodeError (a ValueError, NOT a
|
|
132
|
+
# yaml.YAMLError), so surface it as StoriesError like every other manifest
|
|
133
|
+
# fault — every caller already catches that and prints a clean "stories mode:"
|
|
134
|
+
# error instead of crashing preflight/dry-run/status with a traceback.
|
|
135
|
+
raise StoriesError(f"stories.yaml is not valid UTF-8: {path}: {e}") from e
|
|
136
|
+
except OSError as e:
|
|
137
|
+
# Same rule for a manifest that is present but cannot be read (permissions,
|
|
138
|
+
# an I/O error, a dead mount). `is_file()` above only rules out absence, so
|
|
139
|
+
# this escaped as a bare OSError and crashed the run from whichever caller
|
|
140
|
+
# happened to hit it first — including the commit-boundary read of
|
|
141
|
+
# `closes_deferred`, whose whole contract is to journal an unreadable
|
|
142
|
+
# manifest and carry on with the spec channel (#284 round-5 review,
|
|
143
|
+
# finding 5).
|
|
144
|
+
raise StoriesError(f"stories.yaml could not be read: {path}: {e}") from e
|
|
145
|
+
try:
|
|
146
|
+
doc = yaml.safe_load(raw)
|
|
147
|
+
except yaml.YAMLError as e:
|
|
148
|
+
raise StoriesError(f"stories.yaml is not valid YAML: {path}: {e}") from e
|
|
149
|
+
if doc is None or (isinstance(doc, list) and not doc):
|
|
150
|
+
raise StoriesError("stories.yaml has no story entries")
|
|
151
|
+
if not isinstance(doc, list):
|
|
152
|
+
raise StoriesError("stories.yaml must be a top-level list of story entries")
|
|
153
|
+
|
|
154
|
+
entries: list[StoryEntry] = []
|
|
155
|
+
seen: set[str] = set()
|
|
156
|
+
seen_folded: dict[str, str] = {} # casefolded id -> first id that used it
|
|
157
|
+
for index, raw in enumerate(doc):
|
|
158
|
+
entry = _parse_entry(raw, index)
|
|
159
|
+
if entry.id in seen:
|
|
160
|
+
raise StoriesError(f"stories.yaml has a duplicate id {entry.id!r}")
|
|
161
|
+
# Story specs resolve by the `<id>-*.md` glob, which is case-insensitive on
|
|
162
|
+
# Windows/macOS filesystems (both in the CI matrix), so two ids that differ
|
|
163
|
+
# only by case would cross-match the same files. Reject them up front rather
|
|
164
|
+
# than let resolution become filesystem-dependent.
|
|
165
|
+
folded = entry.id.casefold()
|
|
166
|
+
if folded in seen_folded:
|
|
167
|
+
raise StoriesError(
|
|
168
|
+
f"stories.yaml ids {seen_folded[folded]!r} and {entry.id!r} differ only "
|
|
169
|
+
"by case — story specs resolve by the case-insensitive glob <id>-*.md, so "
|
|
170
|
+
"on a case-insensitive filesystem (Windows/macOS) they would cross-match"
|
|
171
|
+
)
|
|
172
|
+
seen.add(entry.id)
|
|
173
|
+
seen_folded[folded] = entry.id
|
|
174
|
+
entries.append(entry)
|
|
175
|
+
_validate_prefix_free([e.id for e in entries])
|
|
176
|
+
return Stories(path=path, entries=tuple(entries))
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _parse_entry(raw: object, index: int) -> StoryEntry:
|
|
180
|
+
if not isinstance(raw, dict):
|
|
181
|
+
raise StoriesError(f"stories.yaml entry {index} is not a mapping")
|
|
182
|
+
if "status" in raw:
|
|
183
|
+
raise StoriesError(
|
|
184
|
+
f"stories.yaml entry {index} has a forbidden 'status' key — a story's "
|
|
185
|
+
"status lives in its story spec, never in stories.yaml"
|
|
186
|
+
)
|
|
187
|
+
story_id = _parse_id(raw, index)
|
|
188
|
+
return StoryEntry(
|
|
189
|
+
id=story_id,
|
|
190
|
+
title=_require_text(raw, "title", story_id),
|
|
191
|
+
description=_require_text(raw, "description", story_id),
|
|
192
|
+
spec_checkpoint=_bool_field(raw, "spec_checkpoint", story_id),
|
|
193
|
+
done_checkpoint=_bool_field(raw, "done_checkpoint", story_id),
|
|
194
|
+
invoke_dev_with=_text_field(raw, "invoke_dev_with", story_id),
|
|
195
|
+
closes_deferred=_id_list_field(raw, "closes_deferred", story_id),
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _parse_id(raw: dict, index: int) -> str:
|
|
200
|
+
if raw.get("id") is None:
|
|
201
|
+
raise StoriesError(f"stories.yaml entry {index} is missing required field 'id'")
|
|
202
|
+
# str()-normalize: schema rule 4 says ids are quoted strings, but an
|
|
203
|
+
# LLM-authored file may still emit an unquoted `id: 1` (PyYAML -> int) or
|
|
204
|
+
# `id: 3.5` (-> float). Coerce, then charset-validate — a float's `.` fails.
|
|
205
|
+
story_id = str(raw["id"]).strip()
|
|
206
|
+
if not ID_RE.match(story_id):
|
|
207
|
+
raise StoriesError(
|
|
208
|
+
f"stories.yaml entry {index} has invalid id {story_id!r}: ids must be "
|
|
209
|
+
"letters, digits, and dashes (^[A-Za-z0-9]+(-[A-Za-z0-9]+)*$)"
|
|
210
|
+
)
|
|
211
|
+
return story_id
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _require_text(raw: dict, key: str, story_id: str) -> str:
|
|
215
|
+
value = raw.get(key)
|
|
216
|
+
if value is None:
|
|
217
|
+
raise StoriesError(f"stories.yaml story {story_id!r} is missing required field {key!r}")
|
|
218
|
+
if not isinstance(value, str):
|
|
219
|
+
raise StoriesError(f"stories.yaml story {story_id!r} field {key!r} must be a string")
|
|
220
|
+
value = value.strip()
|
|
221
|
+
if not value:
|
|
222
|
+
raise StoriesError(f"stories.yaml story {story_id!r} field {key!r} is empty")
|
|
223
|
+
return value
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _bool_field(raw: dict, key: str, story_id: str) -> bool:
|
|
227
|
+
"""A checkpoint flag: bool, defaulting False when missing/null. Strict — a
|
|
228
|
+
non-bool (`1`, `"true"`) is a schema error, not a silent truthy coercion. In
|
|
229
|
+
Python ``bool`` is an ``int`` subclass, but ``isinstance(1, bool)`` is False,
|
|
230
|
+
so an integer 1 is correctly rejected."""
|
|
231
|
+
value = raw.get(key)
|
|
232
|
+
if value is None:
|
|
233
|
+
return False
|
|
234
|
+
if not isinstance(value, bool):
|
|
235
|
+
raise StoriesError(
|
|
236
|
+
f"stories.yaml story {story_id!r} field {key!r} must be a boolean "
|
|
237
|
+
f"(got {type(value).__name__})"
|
|
238
|
+
)
|
|
239
|
+
return value
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _text_field(raw: dict, key: str, story_id: str) -> str:
|
|
243
|
+
"""Optional free-text field, defaulting "" when missing/null. Not stripped:
|
|
244
|
+
``invoke_dev_with`` is appended to the dispatch prompt verbatim."""
|
|
245
|
+
value = raw.get(key)
|
|
246
|
+
if value is None:
|
|
247
|
+
return ""
|
|
248
|
+
if not isinstance(value, str):
|
|
249
|
+
raise StoriesError(f"stories.yaml story {story_id!r} field {key!r} must be a string")
|
|
250
|
+
return value
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _id_list_field(raw: dict, key: str, story_id: str) -> tuple[str, ...]:
|
|
254
|
+
"""Optional list of ledger ids, defaulting empty when missing/null (#234).
|
|
255
|
+
|
|
256
|
+
The reading itself lives in :func:`deferredwork.parse_declaration`, shared
|
|
257
|
+
with the engine's close hook and ``validate`` so the same mistake cannot mean
|
|
258
|
+
different things in the manifest and in a story spec's frontmatter. Only the
|
|
259
|
+
*severity* differs: a manifest is a schema the parser owns, so a wrong
|
|
260
|
+
container raises here, where the engine journals and ``validate`` warns.
|
|
261
|
+
|
|
262
|
+
Whether an id names a real entry is not checked here: this module never reads
|
|
263
|
+
the ledger, and a stale reference is a `validate` warning and a journaled
|
|
264
|
+
close-time note — never a parse failure.
|
|
265
|
+
"""
|
|
266
|
+
ids, error = deferredwork.parse_declaration(raw.get(key))
|
|
267
|
+
if error:
|
|
268
|
+
raise StoriesError(f"stories.yaml story {story_id!r} field {key!r} {error}")
|
|
269
|
+
return ids
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def _validate_prefix_free(ids: list[str]) -> None:
|
|
273
|
+
"""No id may equal another id plus a ``-suffix`` (schema validity rule 2).
|
|
274
|
+
|
|
275
|
+
Story specs are discovered by the ``<id>-*.md`` glob, so if ``3`` and
|
|
276
|
+
``3-2`` were both ids the ``3-*.md`` glob for story ``3`` would also match
|
|
277
|
+
``3-2-slug.md`` — the id would no longer resolve to a single file. ``3`` vs
|
|
278
|
+
``31`` is fine: ``3-*.md`` never matches ``31-slug.md``.
|
|
279
|
+
|
|
280
|
+
The check is case-insensitive for the same reason ``load_stories`` rejects
|
|
281
|
+
case-only duplicates: on a case-insensitive filesystem ``Auth-*.md`` also
|
|
282
|
+
matches ``auth-2-slug.md``, so ``Auth`` and ``auth-2`` collide there too.
|
|
283
|
+
Case-only duplicates (equal casefold) are caught earlier in ``load_stories``;
|
|
284
|
+
by here every id has a distinct casefold, so this map is unambiguous.
|
|
285
|
+
"""
|
|
286
|
+
by_fold = {i.casefold(): i for i in ids}
|
|
287
|
+
for story_id in ids:
|
|
288
|
+
parts = story_id.casefold().split("-")
|
|
289
|
+
for k in range(1, len(parts)):
|
|
290
|
+
prefix = "-".join(parts[:k])
|
|
291
|
+
other = by_fold.get(prefix)
|
|
292
|
+
if other is not None:
|
|
293
|
+
raise StoriesError(
|
|
294
|
+
f"stories.yaml id {story_id!r} is not prefix-free: {other!r} is "
|
|
295
|
+
f"also an id, so the {other}-*.md glob would match both"
|
|
296
|
+
)
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def find_entry(stories: Stories, story_id: str) -> StoryEntry:
|
|
300
|
+
"""The entry for ``story_id`` or a :class:`StoriesError` with the pinned
|
|
301
|
+
``story id not found in stories.yaml`` message."""
|
|
302
|
+
entry = stories.get(story_id)
|
|
303
|
+
if entry is None:
|
|
304
|
+
raise StoriesError("story id not found in stories.yaml")
|
|
305
|
+
return entry
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
@dataclass(frozen=True)
|
|
309
|
+
class StoryState:
|
|
310
|
+
"""The resolved on-disk state of one story (see :func:`resolve_story_spec`).
|
|
311
|
+
|
|
312
|
+
``status`` is set only for :data:`KIND_PRESENT`; ``path`` for PRESENT /
|
|
313
|
+
SENTINEL; ``paths`` for AMBIGUOUS; ``sentinel_kind`` for SENTINEL.
|
|
314
|
+
"""
|
|
315
|
+
|
|
316
|
+
kind: str
|
|
317
|
+
status: str = ""
|
|
318
|
+
path: Path | None = None
|
|
319
|
+
paths: tuple[Path, ...] = ()
|
|
320
|
+
sentinel_kind: str = ""
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def resolve_story_spec(spec_folder: Path | str, story_id: str) -> StoryState:
|
|
324
|
+
"""Deterministic on-disk state of one story, keyed by id.
|
|
325
|
+
|
|
326
|
+
Globs ``<spec-folder>/stories/<id>-*.md``. Ids are prefix-free, so a
|
|
327
|
+
conforming tree yields at most one match: no match = :data:`KIND_PENDING`
|
|
328
|
+
(never dispatched); a fixed-slug ``<id>-unresolved.md`` / ``<id>-ambiguous.md``
|
|
329
|
+
= :data:`KIND_SENTINEL`; any other single file = :data:`KIND_PRESENT` with
|
|
330
|
+
its frontmatter status read off disk. More than one match =
|
|
331
|
+
:data:`KIND_AMBIGUOUS` (an anomaly the dispatcher must refuse rather than
|
|
332
|
+
silently pick one).
|
|
333
|
+
|
|
334
|
+
The glob result is filtered to names starting with the **exact-case**
|
|
335
|
+
``<id>-`` prefix so resolution is deterministic across filesystems: a
|
|
336
|
+
case-insensitive FS (Windows/macOS) would otherwise let ``Auth-*.md`` also
|
|
337
|
+
match ``auth-2-slug.md``, matching what a case-sensitive FS (Linux) never
|
|
338
|
+
would. This keeps resolution in step with the exact-case sentinel comparison
|
|
339
|
+
below and verify's id-prefix gate — a wrong-case hit that resolved here would
|
|
340
|
+
only fail those and cause a spurious retry.
|
|
341
|
+
"""
|
|
342
|
+
sid = str(story_id).strip()
|
|
343
|
+
if not ID_RE.match(sid):
|
|
344
|
+
# An id that isn't charset-valid can't name a conforming `<id>-*.md` file
|
|
345
|
+
# and must never reach glob() — a stray metacharacter (`*`, `?`, `[`) or a
|
|
346
|
+
# path separator would mis-match (or an escape). Every live caller already
|
|
347
|
+
# passes a manifest id validated by load_stories; this guards a future
|
|
348
|
+
# caller that doesn't. A clean "no resolvable spec" (PENDING) is what every
|
|
349
|
+
# caller already handles, matching the module's fail-loud-not-slip rule.
|
|
350
|
+
return StoryState(kind=KIND_PENDING)
|
|
351
|
+
stories_dir = Path(spec_folder) / STORIES_SUBDIR
|
|
352
|
+
matches = (
|
|
353
|
+
sorted(m for m in stories_dir.glob(f"{sid}-*.md") if m.name.startswith(f"{sid}-"))
|
|
354
|
+
if stories_dir.is_dir()
|
|
355
|
+
else []
|
|
356
|
+
)
|
|
357
|
+
if not matches:
|
|
358
|
+
return StoryState(kind=KIND_PENDING)
|
|
359
|
+
if len(matches) > 1:
|
|
360
|
+
return StoryState(kind=KIND_AMBIGUOUS, paths=tuple(matches))
|
|
361
|
+
path = matches[0]
|
|
362
|
+
for sentinel_kind in SENTINEL_SLUGS:
|
|
363
|
+
if path.name == f"{sid}-{sentinel_kind}.md":
|
|
364
|
+
return StoryState(kind=KIND_SENTINEL, path=path, sentinel_kind=sentinel_kind)
|
|
365
|
+
try:
|
|
366
|
+
status = status_of(read_frontmatter(path))
|
|
367
|
+
except (OSError, UnicodeDecodeError):
|
|
368
|
+
# An undecodable (binary/non-UTF-8) or mid-glob-vanished PRESENT spec has an
|
|
369
|
+
# unknown status: degrade rather than crash the scheduler / dry-run / status.
|
|
370
|
+
# status="" classifies as "wedged" (_classify) so the engine pauses for
|
|
371
|
+
# resolve — never silently skips — and state_label renders it as "present".
|
|
372
|
+
status = ""
|
|
373
|
+
return StoryState(kind=KIND_PRESENT, status=status, path=path)
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
@dataclass(frozen=True)
|
|
377
|
+
class Schedule:
|
|
378
|
+
"""The scheduler's verdict. ``outcome`` is one of :data:`SCHEDULE_NEXT`
|
|
379
|
+
(``entry`` is the next story to dispatch), :data:`SCHEDULE_COMPLETE` (all
|
|
380
|
+
done), or :data:`SCHEDULE_WEDGED` (``entry``/``state`` name the
|
|
381
|
+
blocked/sentinel/ambiguous story that stopped the scan)."""
|
|
382
|
+
|
|
383
|
+
outcome: str
|
|
384
|
+
entry: StoryEntry | None = None
|
|
385
|
+
state: StoryState | None = None
|
|
386
|
+
|
|
387
|
+
@property
|
|
388
|
+
def is_complete(self) -> bool:
|
|
389
|
+
return self.outcome == SCHEDULE_COMPLETE
|
|
390
|
+
|
|
391
|
+
@property
|
|
392
|
+
def is_wedged(self) -> bool:
|
|
393
|
+
return self.outcome == SCHEDULE_WEDGED
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def schedule(
|
|
397
|
+
stories: Stories,
|
|
398
|
+
states: dict[str, StoryState],
|
|
399
|
+
selector: str | None = None,
|
|
400
|
+
skip: set[str] | None = None,
|
|
401
|
+
) -> Schedule:
|
|
402
|
+
"""Linear scheduler: the first list entry ready to (re)dispatch.
|
|
403
|
+
|
|
404
|
+
The manifest is a flat list in strict execution order (no ``depends_on``),
|
|
405
|
+
so scheduling is a single left-to-right scan. An entry is actionable when
|
|
406
|
+
its state is PENDING or a resumable non-terminal (``draft`` / ``ready-for-dev``
|
|
407
|
+
/ ``in-progress`` / ``in-review`` = died mid-flight, re-dispatch resumes); a
|
|
408
|
+
``done`` entry is skipped (never re-dispatch done); a ``blocked``, sentinel,
|
|
409
|
+
ambiguous, or unknown-status entry STOPS the scan (:data:`SCHEDULE_WEDGED` —
|
|
410
|
+
the run pauses for resolve; a blocked story cannot be leapfrogged to later
|
|
411
|
+
work). Falling off the end with everything done is :data:`SCHEDULE_COMPLETE`.
|
|
412
|
+
|
|
413
|
+
``selector`` restricts the scan to a single story id (raises when the id is
|
|
414
|
+
unknown), for ``--story`` runs. A state missing from ``states`` is treated
|
|
415
|
+
as PENDING (no spec on disk).
|
|
416
|
+
|
|
417
|
+
``skip`` is the orchestrator's within-run memory: ids already driven to a
|
|
418
|
+
terminal phase *this run* (done, or plateau-deferred). They are passed over
|
|
419
|
+
like a ``done`` entry — the scan continues past them rather than stopping or
|
|
420
|
+
re-dispatching. This mirrors the sprint engine's ``base_skip`` and is what
|
|
421
|
+
keeps a deferred story (whose on-disk spec may still read as a resumable
|
|
422
|
+
non-terminal) from being re-picked forever within one run.
|
|
423
|
+
"""
|
|
424
|
+
skip = skip or set()
|
|
425
|
+
entries: tuple[StoryEntry, ...]
|
|
426
|
+
entries = (find_entry(stories, selector),) if selector is not None else stories.entries
|
|
427
|
+
for entry in entries:
|
|
428
|
+
if entry.id in skip:
|
|
429
|
+
continue
|
|
430
|
+
state = states.get(entry.id)
|
|
431
|
+
if state is None:
|
|
432
|
+
state = StoryState(kind=KIND_PENDING)
|
|
433
|
+
disposition = _classify(state)
|
|
434
|
+
if disposition == "actionable":
|
|
435
|
+
return Schedule(SCHEDULE_NEXT, entry=entry, state=state)
|
|
436
|
+
if disposition == "done":
|
|
437
|
+
continue
|
|
438
|
+
return Schedule(SCHEDULE_WEDGED, entry=entry, state=state)
|
|
439
|
+
return Schedule(SCHEDULE_COMPLETE)
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
def _classify(state: StoryState) -> str:
|
|
443
|
+
"""One of ``'actionable'`` | ``'done'`` | ``'wedged'`` for a resolved state."""
|
|
444
|
+
if state.kind == KIND_PENDING:
|
|
445
|
+
return "actionable"
|
|
446
|
+
if state.kind == KIND_PRESENT:
|
|
447
|
+
if state.status == DONE:
|
|
448
|
+
return "done"
|
|
449
|
+
if state.status in RESUMABLE_STATUSES:
|
|
450
|
+
return "actionable"
|
|
451
|
+
# blocked, or a status the skill itself would HALT on as unrecognized.
|
|
452
|
+
return "wedged"
|
|
453
|
+
# AMBIGUOUS or SENTINEL — not actionable without dispatcher/human recovery.
|
|
454
|
+
return "wedged"
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
# ------------------------------------------------------------ table projection
|
|
458
|
+
#
|
|
459
|
+
# A read-only, disk-derived view of a stories manifest shared by the CLI (`status`
|
|
460
|
+
# / `run --dry-run`) and the TUI stories table, so every surface agrees on the
|
|
461
|
+
# same human-facing state string. Pure: no engine or RunState coupling.
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
def resolve_spec_folder(project: Path, spec_folder: str) -> Path:
|
|
465
|
+
"""The absolute spec folder for ``spec_folder`` (project-relative or already
|
|
466
|
+
absolute) under ``project`` — the one place the folder anchoring lives so
|
|
467
|
+
the CLI, dry-run, preflight and TUI resolve it identically."""
|
|
468
|
+
folder = Path(spec_folder)
|
|
469
|
+
return folder if folder.is_absolute() else project / folder
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
def relativize_spec_folder(project: Path, spec_folder: str) -> str:
|
|
473
|
+
"""The project-relative posix form of ``spec_folder`` — what the orchestrator
|
|
474
|
+
actually dispatches (``FROID_LOOP_SPEC_FOLDER`` / the ``Spec folder:`` prompt).
|
|
475
|
+
The one place this lives so the engine's real dispatch and the CLI dry-run
|
|
476
|
+
render the identical folder string.
|
|
477
|
+
|
|
478
|
+
Three answers and one refusal. A relative spelling is kept verbatim; an
|
|
479
|
+
absolute one inside the project tree is rebased onto the root; an absolute one
|
|
480
|
+
genuinely outside it is kept verbatim too — an external spec folder is a
|
|
481
|
+
supported layout (``[stories] source``), though we never author one. A host
|
|
482
|
+
that cannot canonicalize either operand raises :class:`StoriesError`
|
|
483
|
+
(#552/#560) instead of answering: the location is unknowable, and every sink
|
|
484
|
+
takes the string as given — ``FROID_LOOP_SPEC_FOLDER``, the dev prompt,
|
|
485
|
+
``RunState.spec_folder`` and the post-session verification all consume it
|
|
486
|
+
verbatim, and ``StoriesEngine._stories_folder`` anchors only a *relative*
|
|
487
|
+
answer on the live workspace root. Handing back an unverified absolute path
|
|
488
|
+
would therefore skip the anchor entirely and point an isolated story's reads
|
|
489
|
+
and writes at the main checkout; refusing costs a run that could not have been
|
|
490
|
+
dispatched correctly anyway."""
|
|
491
|
+
raw = Path(spec_folder)
|
|
492
|
+
if not raw.is_absolute():
|
|
493
|
+
return raw.as_posix()
|
|
494
|
+
try:
|
|
495
|
+
return raw.resolve().relative_to(project.resolve()).as_posix()
|
|
496
|
+
except ValueError:
|
|
497
|
+
# Both sides canonicalized and simply share no prefix: genuinely outside
|
|
498
|
+
# the project tree, which the contract allows — keep it verbatim.
|
|
499
|
+
return raw.as_posix()
|
|
500
|
+
except (OSError, RuntimeError) as e:
|
|
501
|
+
raise StoriesError(
|
|
502
|
+
f"cannot canonicalize the spec folder {spec_folder!r} against the project "
|
|
503
|
+
f"root {str(project)!r}: {e} — whether it lies inside or outside the "
|
|
504
|
+
"project tree cannot be determined, so no run can safely dispatch it. "
|
|
505
|
+
"Run `froid-loop validate` for what this host is doing."
|
|
506
|
+
) from e
|
|
507
|
+
|
|
508
|
+
|
|
509
|
+
def is_plan_halt_leg(spec_checkpoint: bool, state: StoryState) -> bool:
|
|
510
|
+
"""Whether a ``spec_checkpoint`` story's next dispatch HALTs after planning
|
|
511
|
+
(leg 1) given its resolved on-disk ``state``.
|
|
512
|
+
|
|
513
|
+
True only for a spec_checkpoint story whose plan has not yet reached
|
|
514
|
+
``ready-for-dev`` on disk; once the plan exists (leg 2 after the plan
|
|
515
|
+
checkpoint, or a repair that reset the spec to in-progress) it is the plain
|
|
516
|
+
implement leg. Pure predicate shared by the engine's ``_plan_halt_leg`` and
|
|
517
|
+
the CLI dry-run so both key off the same on-disk state."""
|
|
518
|
+
if not spec_checkpoint:
|
|
519
|
+
return False
|
|
520
|
+
if state.kind == KIND_PRESENT and state.status in PLAN_PRODUCED_STATUSES:
|
|
521
|
+
return False
|
|
522
|
+
return True
|
|
523
|
+
|
|
524
|
+
|
|
525
|
+
_AUTO_RUN_RESULT_HEADING = "## Auto Run Result"
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
def recorded_blocking_condition(sentinel_text: str) -> str:
|
|
529
|
+
"""The blocking condition a pre-planning-halt sentinel records under its
|
|
530
|
+
``## Auto Run Result`` heading — the reason planning could not proceed.
|
|
531
|
+
|
|
532
|
+
Returns the block body (heading dropped, collapsed to a single line), or ""
|
|
533
|
+
when the sentinel carries no such block. A write-only breadcrumb for the
|
|
534
|
+
``sentinel-cleared`` / ``sentinel-detected`` journal events and the resolve
|
|
535
|
+
context; never parsed back into a decision."""
|
|
536
|
+
idx = sentinel_text.find(_AUTO_RUN_RESULT_HEADING)
|
|
537
|
+
if idx == -1:
|
|
538
|
+
return ""
|
|
539
|
+
body = sentinel_text[idx + len(_AUTO_RUN_RESULT_HEADING) :]
|
|
540
|
+
next_heading = body.find("\n## ")
|
|
541
|
+
if next_heading != -1:
|
|
542
|
+
body = body[:next_heading]
|
|
543
|
+
return " ".join(body.split())
|
|
544
|
+
|
|
545
|
+
|
|
546
|
+
def state_label(state: StoryState) -> str:
|
|
547
|
+
"""The single human-facing state string for a resolved story, matching the
|
|
548
|
+
dispatch-protocol read model: PRESENT shows its frontmatter status
|
|
549
|
+
(``draft`` / ``ready-for-dev`` / ``in-progress`` / ``in-review`` / ``done`` /
|
|
550
|
+
``blocked``); PENDING / AMBIGUOUS show the kind; a SENTINEL shows
|
|
551
|
+
``sentinel:<unresolved|ambiguous>`` so the recoverable-by-deletion anomaly
|
|
552
|
+
reads distinctly from a real ``ambiguous`` (two rival specs)."""
|
|
553
|
+
if state.kind == KIND_PRESENT:
|
|
554
|
+
return state.status or KIND_PRESENT
|
|
555
|
+
if state.kind == KIND_SENTINEL:
|
|
556
|
+
return f"sentinel:{state.sentinel_kind}" if state.sentinel_kind else KIND_SENTINEL
|
|
557
|
+
return state.kind # pending / ambiguous
|
|
558
|
+
|
|
559
|
+
|
|
560
|
+
@dataclass(frozen=True)
|
|
561
|
+
class StoryRow:
|
|
562
|
+
"""One row of a stories-mode status table: the manifest fields (id / title /
|
|
563
|
+
checkpoint flags) joined with the live on-disk state of the id-keyed story
|
|
564
|
+
spec. ``position`` is 1-based list order."""
|
|
565
|
+
|
|
566
|
+
position: int
|
|
567
|
+
id: str
|
|
568
|
+
title: str
|
|
569
|
+
spec_checkpoint: bool
|
|
570
|
+
done_checkpoint: bool
|
|
571
|
+
state: StoryState
|
|
572
|
+
label: str
|
|
573
|
+
|
|
574
|
+
|
|
575
|
+
def story_rows(
|
|
576
|
+
spec_folder: Path | str,
|
|
577
|
+
*,
|
|
578
|
+
selector: str | None = None,
|
|
579
|
+
max_stories: int | None = None,
|
|
580
|
+
) -> list[StoryRow]:
|
|
581
|
+
"""Load ``stories.yaml`` and project every entry to a :class:`StoryRow`,
|
|
582
|
+
resolving each story's on-disk state. ``selector`` restricts to one id
|
|
583
|
+
(empty result when unknown — the caller decides how to report that);
|
|
584
|
+
``max_stories`` truncates like the run limit: it counts only stories the run
|
|
585
|
+
would actually drive (the engine's durable dispatch count skips already-done
|
|
586
|
+
stories), so done rows before the cap stay in view as skipped context and a
|
|
587
|
+
non-positive cap previews an empty schedule, exactly like the run dispatches
|
|
588
|
+
nothing. Raises :class:`StoriesError` when the manifest is missing or
|
|
589
|
+
invalid, so a caller rendering a table can surface the same message the run
|
|
590
|
+
would HALT on."""
|
|
591
|
+
folder = Path(spec_folder)
|
|
592
|
+
story_set = load_stories(folder)
|
|
593
|
+
entries = story_set.entries
|
|
594
|
+
if selector is not None:
|
|
595
|
+
entries = tuple(e for e in entries if e.id == selector)
|
|
596
|
+
rows: list[StoryRow] = []
|
|
597
|
+
dispatchable = 0
|
|
598
|
+
for position, entry in enumerate(entries, 1):
|
|
599
|
+
if max_stories is not None and dispatchable >= max_stories:
|
|
600
|
+
break
|
|
601
|
+
state = resolve_story_spec(folder, entry.id)
|
|
602
|
+
if _classify(state) != "done":
|
|
603
|
+
dispatchable += 1
|
|
604
|
+
rows.append(
|
|
605
|
+
StoryRow(
|
|
606
|
+
position=position,
|
|
607
|
+
id=entry.id,
|
|
608
|
+
title=entry.title,
|
|
609
|
+
spec_checkpoint=entry.spec_checkpoint,
|
|
610
|
+
done_checkpoint=entry.done_checkpoint,
|
|
611
|
+
state=state,
|
|
612
|
+
label=state_label(state),
|
|
613
|
+
)
|
|
614
|
+
)
|
|
615
|
+
return rows
|