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/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