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/journal.py ADDED
@@ -0,0 +1,178 @@
1
+ """Append-only run journal and atomic run-state persistence."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import time
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ from .model import RunState
12
+ from .platform_util import (
13
+ DIR_FD_ANCHORED_WRITES,
14
+ atomic_replace,
15
+ atomic_write_text,
16
+ atomic_write_text_at,
17
+ is_link_like,
18
+ open_dir_confined,
19
+ )
20
+
21
+ STATE_FILE = "state.json"
22
+ JOURNAL_FILE = "journal.jsonl"
23
+ LOGS_DIR = "logs"
24
+ # Verifier subprocess streams, deliberately NOT under LOGS_DIR — see
25
+ # Journal.write_verify_stream for why sharing that directory is a TUI bug.
26
+ VERIFY_DIR = "verify"
27
+
28
+
29
+ class Journal:
30
+ def __init__(self, run_dir: Path):
31
+ self.run_dir = run_dir
32
+ self.path = run_dir / JOURNAL_FILE
33
+ self._log_task: str | None = None
34
+ self._log_path: Path | None = None
35
+ run_dir.mkdir(parents=True, exist_ok=True)
36
+
37
+ def set_active_log(self, task_id: str) -> None:
38
+ """Entries from now on carry log_task/log_pos: the pane log of this
39
+ task and its byte size at append time. Deliberately not cleared on
40
+ session end — post-session entries (decisions, story-done) point at
41
+ the end of the log they are about; the next session replaces it."""
42
+ self._log_task = task_id
43
+ self._log_path = self.run_dir / LOGS_DIR / f"{task_id}.log"
44
+
45
+ def append(self, kind: str, **fields: Any) -> None:
46
+ entry = {"ts": time.time(), "kind": kind, **fields}
47
+ if self._log_path is not None:
48
+ try:
49
+ size = self._log_path.stat().st_size
50
+ except OSError:
51
+ size = 0 # pipe-pane has not created the file yet
52
+ entry.setdefault("log_task", self._log_task)
53
+ entry.setdefault("log_pos", size)
54
+ with self.path.open("a", encoding="utf-8") as f:
55
+ f.write(json.dumps(entry, default=str) + "\n")
56
+
57
+ def write_verify_stream(self, name: str, content: str) -> str:
58
+ """Atomically retain one verifier subprocess stream under ``verify/`` and
59
+ return its run-relative pointer. The journal records the pointer and byte
60
+ counts, never unbounded subprocess output inline.
61
+
62
+ Its own directory, not ``logs/``: every other inhabitant of ``logs/`` is a
63
+ coding-CLI pane capture named after a session task id. The adapters own
64
+ that namespace (they write ``{task_id}.log``) and the TUI reads the whole
65
+ directory as one — with no session open, ``tui.data.active_task_id`` falls
66
+ back to the newest ``logs/*.log`` and returns its stem as the live task,
67
+ which the dashboard then reopens as ``logs/{stem}.log``. Verifier streams
68
+ land in exactly that window: session-end is journalled when the session
69
+ ends, before its result reaches verification, so at the moment these files
70
+ are newest no session is open and the fallback fires. Under ``logs/`` that
71
+ rendered verifier stderr in the agent log pane. Keeping the store in a
72
+ separate directory makes that unrepresentable, rather than a name filter
73
+ every future reader of ``logs/`` would have to remember to apply.
74
+
75
+ ``name`` is engine-generated (not plugin or command supplied), so it is
76
+ safe to join below. ``content`` arrives already bounded — the cap is
77
+ ``verify.stream_capture_kb``, applied by the caller, which is also where
78
+ the full-size and truncation bookkeeping lives; this method is journal
79
+ storage only and never decides how much to keep. Callers retain the
80
+ original stream separately in a hook context.
81
+
82
+ :func:`atomic_write_text`, never ``write_text`` (#379) — the rule
83
+ ``install.py`` states flatly. The fixed ``.tmp`` sibling this replaces is
84
+ the collision that helper's own docstring exists to prevent, and its
85
+ fsync-before-replace is what keeps a pointer from ever naming blocks that
86
+ were never written. ``follow_symlinks=False`` because these are
87
+ machine-minted records under a run directory a coding-CLI session can
88
+ reach: honouring a planted link would aim the write at a path of that
89
+ session's choosing, and there is no operator-curated target here to
90
+ preserve (contrast the ledgers the default was built for).
91
+
92
+ Text mode is deliberate, and it is why the record's byte counts are
93
+ defined over the *stream*, not the file: ``\\n`` is translated on Windows,
94
+ so the file can be larger there than the count. ``read_text`` normalizes
95
+ it back, so the content round-trips either way.
96
+
97
+ The write is **anchored at a directory descriptor** where the platform has
98
+ one, because ``follow_symlinks=False`` covers the final component and
99
+ nothing above it. Sessions are handed this run directory outright
100
+ (``FROID_LOOP_RUN_DIR``, which is where they write ``result.json``), so a
101
+ session that plants a symlink at ``verify/`` before verification redirects
102
+ every record: ``mkdir(exist_ok=True)`` ACCEPTS a symlink-to-directory —
103
+ it re-raises only when ``is_dir()`` is false, and that follows links — and
104
+ the replace then lands wherever the link points, outside the run dir
105
+ entirely. Measured, not theorised.
106
+
107
+ ``open_dir_confined`` is the fix the repo already keeps for exactly this
108
+ (``tui/launch.py`` writes its control-window record the same way): it walks
109
+ each component below the run dir ``O_NOFOLLOW`` and hands back a descriptor
110
+ for the directory it actually reached, and :func:`atomic_write_text_at`
111
+ never names a path again. A path check would be answered *about a path*
112
+ and stale the moment it returned — the session can re-plant the link
113
+ between check and write — so this closes the window rather than narrowing
114
+ it. The ``mkdir`` above may still be fooled; that is harmless, because the
115
+ confinement walk that follows is not, and refusal is what the fooled case
116
+ produces.
117
+
118
+ win32 has no ``*at()`` family to anchor against, so it keeps a
119
+ check-then-write, and the check is :func:`is_link_like` rather than
120
+ ``is_symlink()`` — on Windows the redirect that matters is a DIRECTORY
121
+ JUNCTION, which ``is_symlink()`` reports False for and which ``mklink /J``
122
+ creates with no elevation at all, while a directory symlink needs
123
+ SeCreateSymbolicLinkPrivilege or Developer Mode. Checking only for
124
+ symlinks there would leave the unprivileged half of the same escape open,
125
+ and with no race to win. The residual is the platform's: a path check is
126
+ stale the moment it returns, but the planting session runs as the same uid
127
+ as this writer and the names here are engine-minted, so the exposure is a
128
+ redirected diagnostic rather than a foothold.
129
+
130
+ Raises ``OSError`` — including when confinement cannot be established, so
131
+ an unconfined ``verify/`` REFUSES rather than writing through the link.
132
+ The caller degrades (this is observation), it does not swallow it here:
133
+ the record still lands, with a null pointer and ``capture_error``.
134
+ """
135
+ verify_dir = self.run_dir / VERIFY_DIR
136
+ verify_dir.mkdir(parents=True, exist_ok=True)
137
+ if DIR_FD_ANCHORED_WRITES:
138
+ dir_fd = open_dir_confined(self.run_dir, verify_dir)
139
+ if dir_fd is None:
140
+ raise OSError(
141
+ f"refusing to write into an unconfined verify directory: {verify_dir}"
142
+ )
143
+ try:
144
+ atomic_write_text_at(dir_fd, name, content)
145
+ finally:
146
+ os.close(dir_fd)
147
+ else:
148
+ if is_link_like(verify_dir):
149
+ raise OSError(f"refusing to write into a redirected verify directory: {verify_dir}")
150
+ atomic_write_text(verify_dir / name, content, follow_symlinks=False)
151
+ return (verify_dir / name).relative_to(self.run_dir).as_posix()
152
+
153
+ def entries(self) -> list[dict[str, Any]]:
154
+ if not self.path.is_file():
155
+ return []
156
+ out = []
157
+ for line in self.path.read_text(encoding="utf-8").splitlines():
158
+ line = line.strip()
159
+ if not line:
160
+ continue
161
+ try:
162
+ out.append(json.loads(line))
163
+ except json.JSONDecodeError:
164
+ continue
165
+ return out
166
+
167
+
168
+ def save_state(run_dir: Path, state: RunState) -> None:
169
+ run_dir.mkdir(parents=True, exist_ok=True)
170
+ target = run_dir / STATE_FILE
171
+ tmp = target.with_suffix(".json.tmp")
172
+ tmp.write_text(json.dumps(state.to_dict(), indent=2), encoding="utf-8")
173
+ atomic_replace(tmp, target)
174
+
175
+
176
+ def load_state(run_dir: Path) -> RunState:
177
+ target = run_dir / STATE_FILE
178
+ return RunState.from_dict(json.loads(target.read_text(encoding="utf-8")))
froid_loop/machine.py ADDED
@@ -0,0 +1,148 @@
1
+ """The machine-readable output contract for CLI ``--json`` modes.
2
+
3
+ ``--json`` in its pure-document form means exactly one JSON object on stdout
4
+ and nothing else — no trailers, no fenced blocks, no log lines. Every document
5
+ carries an inline integer ``schema_version``; each command owns its own version
6
+ constant (e.g. ``cli.STATUS_SCHEMA_VERSION``) so the documents evolve
7
+ independently, the same convention as ``diagnostics.SCHEMA_VERSION``. Evolution
8
+ is additive-only: new fields may appear, but anything breaking — removing or
9
+ renaming a field, changing a type or the meaning of a value — bumps that
10
+ command's version.
11
+
12
+ Errors never produce a partial or error document: the message goes to stderr,
13
+ stdout stays empty, and the exit code is nonzero. Consumers may rely on
14
+ "stdout is either one complete valid document or empty".
15
+
16
+ An **error** is a command that could not do its job — no runs to dump, an
17
+ unresolvable run ref, a policy that will not parse. A command whose job is to
18
+ report a *verdict* is a different thing: it did its job, and it exits nonzero to
19
+ carry the answer. ``validate --json`` is the case — a failing check is the
20
+ finding it was asked for, and the document is still owed. So the rule for
21
+ consumers is positive rather than inferred from the exit code: **parse non-empty
22
+ stdout whatever the exit code, and take the verdict from the document's own
23
+ field** (``ok`` on ``validate``). That field, unlike rc, separates "the checks
24
+ failed" from "the command broke": rc 1 is both, ``ok: false`` is only the first,
25
+ and a command that broke leaves nothing on stdout to read the field from. Note
26
+ that every gate in ``cmd_validate`` runs inside a ``try``, so the command has no
27
+ error path of its own — its rc ∈ {0, 1} is purely the verdict.
28
+
29
+ **Success does not imply a document on stdout.** ``diagnose`` and
30
+ ``probe-adapter`` also take ``--out FILE``; with both flags the document goes to
31
+ the file and stdout is legitimately empty at exit 0, with only a confirmation on
32
+ stderr. So empty stdout means "no document *here*" — check the exit code to tell
33
+ a redirect from a failure, and do not treat rc 0 as a promise of bytes to parse.
34
+ That file is held to the same standard as the stream: :func:`write_document` is
35
+ the only way it is written, and validates exactly as :func:`emit_document` does.
36
+
37
+ Every command that takes ``--json`` shares this contract; there is no exception
38
+ (#195 removed the last two, ``diagnose`` and ``probe-adapter``, which used to
39
+ append a fenced JSON block to a markdown/text report). A command adopting the
40
+ flag adopts the whole of it — the contract is the flag's meaning, not a style
41
+ the earlier commands happen to follow.
42
+
43
+ Two ways in, by what the caller already holds:
44
+
45
+ - :func:`emit` takes a ``dict`` and serializes it — ``status`` and ``list``.
46
+ - :func:`emit_document` takes an already-serialized ``str`` — ``diagnose`` and
47
+ ``probe-adapter``, whose renderers return text. For both this is a hard
48
+ constraint: each serializes *before* running the shared leak self-check
49
+ (``sanitize.guard``), making those exact bytes the ones the check verified —
50
+ re-encoding them here would emit bytes nothing verified.
51
+
52
+ :func:`write_document` is the ``--out`` sibling of :func:`emit_document`, taking
53
+ the same already-serialized string to a file instead of stdout.
54
+
55
+ The serializer flags differ by family on purpose and are not to be unified: the
56
+ renderers behind :func:`emit_document` sort their keys (a diff-stable dump is
57
+ worth more than field order there) while :func:`emit`'s dicts are built in the
58
+ order they are meant to be read, and the two guarded renderers pass
59
+ ``ensure_ascii=False`` because the leak guard has to scan the values unescaped.
60
+ """
61
+
62
+ from __future__ import annotations
63
+
64
+ import argparse
65
+ import json
66
+ import sys
67
+ from pathlib import Path
68
+
69
+
70
+ def emit(doc: dict[str, object]) -> None:
71
+ """The single stdout write in JSON mode."""
72
+ emit_document(json.dumps(doc, indent=2))
73
+
74
+
75
+ def _validated(rendered: str) -> str:
76
+ """Return ``rendered`` unchanged, or raise if it is not a whole JSON document.
77
+
78
+ Parse to assert, never to re-serialize: the caller's exact bytes are what gets
79
+ written. The ``diagnose``/``probe-adapter`` renderers run their leak self-check
80
+ *before* handing the string over, so re-encoding here would ship bytes nothing
81
+ checked.
82
+ """
83
+ try:
84
+ json.loads(rendered)
85
+ except json.JSONDecodeError as e:
86
+ raise ValueError(f"refusing to emit a malformed JSON document: {e}") from e
87
+ return rendered
88
+
89
+
90
+ def emit_document(rendered: str) -> None:
91
+ """The single stdout write, for a document that is *already* serialized.
92
+
93
+ Verifies well-formedness before writing, then writes the ORIGINAL string —
94
+ never a re-serialization of the parsed result. Half the commands reach stdout
95
+ through here rather than through :func:`emit`, so without this the contract
96
+ would hold for them by convention only; but the guarded renderers validated
97
+ these exact bytes with their leak self-check, so emitting anything re-derived
98
+ would ship bytes nothing checked. Parse to assert, print verbatim.
99
+
100
+ Raises :class:`ValueError` on a malformed document — a bug in the caller's
101
+ renderer, and far better surfaced as a crash with empty stdout (which the
102
+ contract permits) than as a half-parsable stream a consumer has to diagnose.
103
+
104
+ stdout is switched to UTF-8 first, because a document is not necessarily
105
+ ASCII — the guarded renderers dump with ``ensure_ascii=False`` so the
106
+ leak guard can scan the values unescaped, which lets a non-sensitive
107
+ non-ASCII field (a localized ``platform.release()``, say) through to here
108
+ verbatim. Encoding it for a legacy non-UTF-8 console then raised
109
+ :class:`UnicodeEncodeError` before a byte was written (#200). Escaping the
110
+ output instead would have been the smaller change and the wrong one: it
111
+ breaks the invariant this function exists for, since the guard verified the
112
+ unescaped bytes. So the stream is made able to carry the document rather
113
+ than the document cut down to fit the stream.
114
+ """
115
+ document = _validated(rendered)
116
+ # Guarded: a substituted stdout (pytest capture, an exotic stream) may not be
117
+ # a TextIOWrapper at all. Falling through leaves the pre-#200 behaviour, which
118
+ # is a crash with stdout still empty — permitted by the contract above.
119
+ if hasattr(sys.stdout, "reconfigure"):
120
+ # hasattr-guarded: reconfigure exists on TextIOWrapper, not the TextIO base.
121
+ sys.stdout.reconfigure( # pyright: ignore[reportAttributeAccessIssue, reportUnknownMemberType]
122
+ encoding="utf-8"
123
+ )
124
+ print(document)
125
+
126
+
127
+ def write_document(path: Path, rendered: str) -> None:
128
+ """The single file write in JSON mode — the bytes :func:`emit_document` prints.
129
+
130
+ ``--out FILE`` is the other half of the same contract, so it gets the same
131
+ validation and the same trailing newline: ``--json --out FILE`` and
132
+ ``--json > FILE`` produce byte-identical files. Without the parse the file was
133
+ the weaker half — stdout refused a malformed document while the file accepted
134
+ it, which is backwards, since a document written to a file is the one nobody
135
+ eyeballs before feeding it to a parser.
136
+
137
+ Raises :class:`ValueError` on a malformed document, before the file is created.
138
+ """
139
+ path.write_text(_validated(rendered) + "\n", encoding="utf-8")
140
+
141
+
142
+ def add_json_flag(parser: argparse.ArgumentParser, what: str) -> None:
143
+ """Register ``--json`` with the standard help text."""
144
+ parser.add_argument(
145
+ "--json",
146
+ action="store_true",
147
+ help=f"emit a stable machine-readable JSON document ({what}) instead of text",
148
+ )