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
|
File without changes
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Coding-CLI hook relay for froid-loop. Stdlib only.
|
|
3
|
+
|
|
4
|
+
Each CLI's hook config registers this script under its native event names
|
|
5
|
+
(Claude/Codex: SessionStart/Stop/..., Gemini: AfterAgent for Stop, Copilot:
|
|
6
|
+
agentStop for Stop) but always passes the CANONICAL event name as argv[1] — the
|
|
7
|
+
orchestrator only ever sees canonical events. Payload keys vary too: snake_case
|
|
8
|
+
(claude/codex), conversation_id (cursor), or camelCase (copilot's sessionId/
|
|
9
|
+
transcriptPath, agy's conversationId/transcriptPath — protojson encoding); the
|
|
10
|
+
field extraction below tries each. agy alone carries no cwd, sending the
|
|
11
|
+
workspacePaths list instead. Reads the hook payload
|
|
12
|
+
from stdin and writes one event file
|
|
13
|
+
into the orchestrator's events directory. No-ops (exit 0) unless the session was
|
|
14
|
+
spawned by froid-loop (detected via env vars set on the tmux window), so
|
|
15
|
+
normal interactive sessions are unaffected.
|
|
16
|
+
|
|
17
|
+
Where that directory is: $FROID_LOOP_EVENTS_DIR when the orchestrator names one,
|
|
18
|
+
else the legacy $FROID_LOOP_RUN_DIR/events. The channel moved out of the project
|
|
19
|
+
tree in #494 so a branch switch or a worktree mount cannot take a live run's
|
|
20
|
+
control plane away, and this script is COPIED into the target project at init
|
|
21
|
+
time — so an upgraded orchestrator regularly drives sessions whose installed
|
|
22
|
+
copy is this file's older self, which knows only the legacy path. That pairing
|
|
23
|
+
is what the orchestrator's own dual poll (signals.SignalWatcher) covers.
|
|
24
|
+
|
|
25
|
+
$FROID_LOOP_EVENTS_DIR is deliberately NOT part of the no-op detector above: the
|
|
26
|
+
mirror-image skew — an older orchestrator that sets only RUN_DIR/TASK_ID driving
|
|
27
|
+
a session whose installed relay is this newer file — must still write its
|
|
28
|
+
events, and requiring the new variable would silently stall every one of those
|
|
29
|
+
runs instead.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
import json
|
|
33
|
+
import os
|
|
34
|
+
import stat
|
|
35
|
+
import sys
|
|
36
|
+
import time
|
|
37
|
+
|
|
38
|
+
# Windows reparse tags that make a directory entry REDIRECT somewhere else,
|
|
39
|
+
# compared against os.lstat().st_reparse_tag (Windows, 3.8+). Deliberately not
|
|
40
|
+
# os.path.isjunction(), which is 3.12+ — this relay runs under whatever
|
|
41
|
+
# interpreter the host has, not under the orchestrator's. Deliberately not "any
|
|
42
|
+
# reparse tag" either: cloud placeholders (OneDrive) and dedup stubs are reparse
|
|
43
|
+
# points too, and refusing those would stall a legitimate run. Empty on POSIX.
|
|
44
|
+
_LINK_REPARSE_TAGS = tuple(
|
|
45
|
+
tag
|
|
46
|
+
for tag in (
|
|
47
|
+
getattr(stat, "IO_REPARSE_TAG_SYMLINK", None),
|
|
48
|
+
getattr(stat, "IO_REPARSE_TAG_MOUNT_POINT", None),
|
|
49
|
+
)
|
|
50
|
+
if tag is not None
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _first_workspace(payload):
|
|
55
|
+
paths = payload.get("workspacePaths")
|
|
56
|
+
if isinstance(paths, list) and paths and isinstance(paths[0], str):
|
|
57
|
+
return paths[0]
|
|
58
|
+
return None
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _is_link_like(path):
|
|
62
|
+
"""True when `path` redirects elsewhere: a POSIX symlink, or a Windows
|
|
63
|
+
symlink OR DIRECTORY JUNCTION.
|
|
64
|
+
|
|
65
|
+
`os.path.islink()` is False for a junction — junctions are a distinct
|
|
66
|
+
reparse kind, which is why `os.path.isjunction()` exists at all. On Windows
|
|
67
|
+
the junction is the arm that matters: `mklink /J` needs no elevation, while
|
|
68
|
+
a directory symlink needs SeCreateSymbolicLinkPrivilege or Developer Mode —
|
|
69
|
+
so the unprivileged attack is exactly the one `islink()` misses.
|
|
70
|
+
"""
|
|
71
|
+
if os.path.islink(path):
|
|
72
|
+
return True
|
|
73
|
+
try:
|
|
74
|
+
return getattr(os.lstat(path), "st_reparse_tag", 0) in _LINK_REPARSE_TAGS
|
|
75
|
+
except OSError:
|
|
76
|
+
return False
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _write_all(fd, data):
|
|
80
|
+
"""Write every byte of `data` to `fd`.
|
|
81
|
+
|
|
82
|
+
`os.write()` may write FEWER bytes than asked and simply return the count. A
|
|
83
|
+
truncated event file is not merely retried, it is lost: `SignalWatcher.poll`
|
|
84
|
+
adds a filename to its consumed set BEFORE parsing it (signals.py), so
|
|
85
|
+
malformed JSON is skipped and never re-read — the session's Stop signal is
|
|
86
|
+
gone for good and the run waits out `session_timeout_min`. The buffered
|
|
87
|
+
`open()` this replaced looped internally; the raw fd needed for
|
|
88
|
+
O_NOFOLLOW/dir_fd does not, so loop here.
|
|
89
|
+
"""
|
|
90
|
+
view = memoryview(data)
|
|
91
|
+
while view:
|
|
92
|
+
written = os.write(fd, view)
|
|
93
|
+
if written <= 0: # not observed in practice; a spinning hook is worse
|
|
94
|
+
raise OSError("short write to the event file")
|
|
95
|
+
view = view[written:]
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _write_event(events_dir, name, event):
|
|
99
|
+
"""Write one event file into `events_dir`, refusing to follow a redirect.
|
|
100
|
+
|
|
101
|
+
The events dir is the orchestrator's control plane. A driven session has
|
|
102
|
+
write access to the project, so it could plant `<run_dir>/events` as a
|
|
103
|
+
symlink (or, on Windows, a junction) and redirect — or swallow — the
|
|
104
|
+
completion signal, stalling the run to `session_timeout_min` instead of
|
|
105
|
+
completing. `os.makedirs(exist_ok=True)` `isdir()`-checks THROUGH such a
|
|
106
|
+
link, so the refusal has to come before it. That refusal works on every
|
|
107
|
+
platform.
|
|
108
|
+
|
|
109
|
+
Where the platform has them, the create+replace is anchored to a dir_fd
|
|
110
|
+
opened O_NOFOLLOW: every later operation goes through that fd, so a swap
|
|
111
|
+
after the check cannot reach the write. Windows has neither
|
|
112
|
+
O_NOFOLLOW/O_DIRECTORY nor a handle-relative open (`os.supports_dir_fd` is
|
|
113
|
+
empty — dir_fd is implemented with the POSIX `*at` calls), so its fallback
|
|
114
|
+
re-resolves the path and the check-to-write window stays open there. It is
|
|
115
|
+
NARROWED, not closed: the redirect check runs again after the payload is
|
|
116
|
+
written and before it is published, so a swap still in place is refused and
|
|
117
|
+
the temp file removed. Two windows stay open on that path (#494), both
|
|
118
|
+
measured: a swap-and-restore around the create is undetectable from stdlib
|
|
119
|
+
Python, and a swap landing after the second check leaves the path-based
|
|
120
|
+
publish unable to find the temp file it wrote — it either raises or renames
|
|
121
|
+
a file the attacker planted inside the attacker's own directory. Neither
|
|
122
|
+
redirects the payload, and both end where a refusal ends: no event, so the
|
|
123
|
+
run waits out session_timeout_min. That is the same outcome an attacker gets
|
|
124
|
+
for free by leaving a redirect in place, which is refused without any race —
|
|
125
|
+
winning the race buys no capability, which is why the residual is accepted
|
|
126
|
+
rather than chased into ctypes/NtCreateFile inside a stdlib-only relay.
|
|
127
|
+
|
|
128
|
+
Mode is 0o600 (narrowed from the umask-derived mode an ordinary `open()`
|
|
129
|
+
produced): only the operator running the loop reads these.
|
|
130
|
+
|
|
131
|
+
Raises OSError on any refusal or failure; the caller degrades to a no-op.
|
|
132
|
+
"""
|
|
133
|
+
if _is_link_like(events_dir):
|
|
134
|
+
raise OSError(f"refusing to write events into a redirected directory: {events_dir}")
|
|
135
|
+
os.makedirs(events_dir, exist_ok=True)
|
|
136
|
+
data = json.dumps(event).encode("utf-8")
|
|
137
|
+
tmp = name + ".tmp"
|
|
138
|
+
o_nofollow = getattr(os, "O_NOFOLLOW", 0)
|
|
139
|
+
o_directory = getattr(os, "O_DIRECTORY", 0)
|
|
140
|
+
# O_BINARY is a no-op flag on POSIX; on Windows it stops the fd from
|
|
141
|
+
# newline-translating what os.write() puts through it.
|
|
142
|
+
create = os.O_WRONLY | os.O_CREAT | os.O_EXCL | o_nofollow | getattr(os, "O_BINARY", 0)
|
|
143
|
+
# Probe os.rename, not os.replace: CPython omits os.replace from
|
|
144
|
+
# supports_dir_fd on Linux even though it accepts src_dir_fd/dst_dir_fd, so
|
|
145
|
+
# probing it would leave this whole branch dead everywhere. This branch is
|
|
146
|
+
# POSIX-only by construction, and there rename(2) IS the atomic-replace
|
|
147
|
+
# primitive os.replace wraps — probe the function actually called.
|
|
148
|
+
if o_nofollow and o_directory and {os.open, os.rename} <= os.supports_dir_fd:
|
|
149
|
+
dir_fd = os.open(events_dir, os.O_RDONLY | o_directory | o_nofollow)
|
|
150
|
+
try:
|
|
151
|
+
fd = os.open(tmp, create, 0o600, dir_fd=dir_fd)
|
|
152
|
+
try:
|
|
153
|
+
_write_all(fd, data)
|
|
154
|
+
finally:
|
|
155
|
+
os.close(fd)
|
|
156
|
+
os.rename(tmp, name, src_dir_fd=dir_fd, dst_dir_fd=dir_fd)
|
|
157
|
+
finally:
|
|
158
|
+
os.close(dir_fd)
|
|
159
|
+
return
|
|
160
|
+
# Fallback (Windows): no dir_fd to anchor to, so the create below re-resolves
|
|
161
|
+
# events_dir by path. A swap into a junction between the check above and this
|
|
162
|
+
# create would have put the temp file inside the attacker's directory. Check
|
|
163
|
+
# again before publishing, so a swap that is still in place is refused rather
|
|
164
|
+
# than followed — the realistic shape, since a junction has to persist to
|
|
165
|
+
# capture the events the attacker is after.
|
|
166
|
+
tmp_path = os.path.join(events_dir, tmp)
|
|
167
|
+
fd = os.open(tmp_path, create, 0o600)
|
|
168
|
+
try:
|
|
169
|
+
_write_all(fd, data)
|
|
170
|
+
finally:
|
|
171
|
+
os.close(fd)
|
|
172
|
+
if _is_link_like(events_dir):
|
|
173
|
+
try:
|
|
174
|
+
os.unlink(tmp_path)
|
|
175
|
+
except OSError:
|
|
176
|
+
pass
|
|
177
|
+
raise OSError(f"events directory was redirected mid-write: {events_dir}")
|
|
178
|
+
os.replace(tmp_path, os.path.join(events_dir, name))
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def main() -> int:
|
|
182
|
+
run_dir = os.environ.get("FROID_LOOP_RUN_DIR")
|
|
183
|
+
task_id = os.environ.get("FROID_LOOP_TASK_ID")
|
|
184
|
+
if not run_dir or not task_id:
|
|
185
|
+
return 0
|
|
186
|
+
event_name = sys.argv[1] if len(sys.argv) > 1 else "Unknown"
|
|
187
|
+
try:
|
|
188
|
+
payload = json.load(sys.stdin)
|
|
189
|
+
except (json.JSONDecodeError, ValueError):
|
|
190
|
+
payload = {}
|
|
191
|
+
if not isinstance(payload, dict):
|
|
192
|
+
payload = {}
|
|
193
|
+
|
|
194
|
+
ts = time.time_ns()
|
|
195
|
+
event = {
|
|
196
|
+
"ts": ts,
|
|
197
|
+
"event": event_name,
|
|
198
|
+
"task_id": task_id,
|
|
199
|
+
# Payload keys vary by CLI: snake_case (claude/codex), conversation_id
|
|
200
|
+
# (cursor), or camelCase (copilot's sessionId/transcriptPath, agy's
|
|
201
|
+
# conversationId). Try each.
|
|
202
|
+
"session_id": (
|
|
203
|
+
payload.get("session_id")
|
|
204
|
+
or payload.get("conversation_id")
|
|
205
|
+
or payload.get("sessionId")
|
|
206
|
+
or payload.get("conversationId")
|
|
207
|
+
),
|
|
208
|
+
"transcript_path": payload.get("transcript_path") or payload.get("transcriptPath"),
|
|
209
|
+
# agy sends no cwd — it sends workspacePaths, a list of workspace roots.
|
|
210
|
+
"cwd": payload.get("cwd") or _first_workspace(payload),
|
|
211
|
+
}
|
|
212
|
+
# The orchestrator's own events dir when it named one, else the legacy
|
|
213
|
+
# in-tree location this file's older selves are still installed at (see the
|
|
214
|
+
# module docstring). `or`, not a presence test: an exported-but-empty value
|
|
215
|
+
# names the launch cwd, which is not a control plane.
|
|
216
|
+
events_dir = os.environ.get("FROID_LOOP_EVENTS_DIR") or os.path.join(run_dir, "events")
|
|
217
|
+
try:
|
|
218
|
+
_write_event(events_dir, f"{ts}-{task_id}-{event_name}.json", event)
|
|
219
|
+
except OSError:
|
|
220
|
+
# A hostile or broken events dir must degrade to the orchestrator's
|
|
221
|
+
# normal session_timeout_min path, never surface as a hook failure that
|
|
222
|
+
# fails the CLI window (mirrors froid_loop_probe_hook.py's write wrap).
|
|
223
|
+
return 0
|
|
224
|
+
return 0
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
if __name__ == "__main__":
|
|
228
|
+
sys.exit(main())
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Full-payload capture hook for `froid-loop probe-adapter --probe`. Stdlib only.
|
|
3
|
+
|
|
4
|
+
A throwaway sibling of froid_loop_hook.py used ONLY during an opt-in live probe.
|
|
5
|
+
It no-ops (exit 0) unless FROID_LOOP_PROBE_CAPTURE_DIR is set — a DISTINCT env var
|
|
6
|
+
from the real relay's FROID_LOOP_RUN_DIR, so the capture hook and the signal relay
|
|
7
|
+
can never fire in each other's context (a normal interactive session sees neither).
|
|
8
|
+
|
|
9
|
+
For every event it writes two files atomically into the capture dir:
|
|
10
|
+
|
|
11
|
+
<ts>-<event>.signal.json SignalWatcher-shaped {ts,event,task_id,session_id,
|
|
12
|
+
transcript_path,cwd} so the probe's completion poll
|
|
13
|
+
(a plain SignalWatcher over the capture dir) works
|
|
14
|
+
with no change to the watcher.
|
|
15
|
+
<ts>-<event>.payload.json the ENTIRE raw stdin payload plus an injected
|
|
16
|
+
"argv_event" (the native event name from argv, for
|
|
17
|
+
native->canonical pairing) so a maintainer can read
|
|
18
|
+
the CLI's exact field names and casing. The probe
|
|
19
|
+
command sanitizes this before it is ever shown;
|
|
20
|
+
nothing written here is displayed raw.
|
|
21
|
+
|
|
22
|
+
Tolerant of empty/garbage stdin and of write errors — it must never crash the
|
|
23
|
+
CLI window it is hooked into.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
import json
|
|
27
|
+
import os
|
|
28
|
+
import sys
|
|
29
|
+
import time
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _first_workspace(payload):
|
|
33
|
+
paths = payload.get("workspacePaths")
|
|
34
|
+
if isinstance(paths, list) and paths and isinstance(paths[0], str):
|
|
35
|
+
return paths[0]
|
|
36
|
+
return None
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _atomic_write(path: str, obj) -> None:
|
|
40
|
+
tmp = path + ".tmp"
|
|
41
|
+
with open(tmp, "w", encoding="utf-8") as f:
|
|
42
|
+
json.dump(obj, f)
|
|
43
|
+
os.replace(tmp, path)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def main() -> int:
|
|
47
|
+
capture_dir = os.environ.get("FROID_LOOP_PROBE_CAPTURE_DIR")
|
|
48
|
+
if not capture_dir:
|
|
49
|
+
return 0
|
|
50
|
+
task_id = os.environ.get("FROID_LOOP_TASK_ID", "probe")
|
|
51
|
+
event_name = sys.argv[1] if len(sys.argv) > 1 else "Unknown"
|
|
52
|
+
try:
|
|
53
|
+
payload = json.load(sys.stdin)
|
|
54
|
+
except (json.JSONDecodeError, ValueError):
|
|
55
|
+
payload = {}
|
|
56
|
+
if not isinstance(payload, dict):
|
|
57
|
+
payload = {}
|
|
58
|
+
|
|
59
|
+
ts = time.time_ns()
|
|
60
|
+
try:
|
|
61
|
+
os.makedirs(capture_dir, exist_ok=True)
|
|
62
|
+
signal = {
|
|
63
|
+
"ts": ts,
|
|
64
|
+
"event": event_name,
|
|
65
|
+
"task_id": task_id,
|
|
66
|
+
# Casing varies by CLI, exactly as in froid_loop_hook.py: snake_case
|
|
67
|
+
# (claude/codex), conversation_id (cursor), camelCase (copilot,
|
|
68
|
+
# agy). agy sends workspacePaths rather than a cwd.
|
|
69
|
+
"session_id": (
|
|
70
|
+
payload.get("session_id")
|
|
71
|
+
or payload.get("conversation_id")
|
|
72
|
+
or payload.get("sessionId")
|
|
73
|
+
or payload.get("conversationId")
|
|
74
|
+
),
|
|
75
|
+
"transcript_path": payload.get("transcript_path") or payload.get("transcriptPath"),
|
|
76
|
+
"cwd": payload.get("cwd") or _first_workspace(payload),
|
|
77
|
+
}
|
|
78
|
+
_atomic_write(os.path.join(capture_dir, f"{ts}-{event_name}.signal.json"), signal)
|
|
79
|
+
captured = dict(payload)
|
|
80
|
+
captured["argv_event"] = event_name
|
|
81
|
+
_atomic_write(os.path.join(capture_dir, f"{ts}-{event_name}.payload.json"), captured)
|
|
82
|
+
except OSError:
|
|
83
|
+
return 0
|
|
84
|
+
return 0
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
if __name__ == "__main__":
|
|
88
|
+
sys.exit(main())
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# A data-only example plugin: the smallest thing the plugin system can load.
|
|
2
|
+
#
|
|
3
|
+
# It declares metadata and one setting, but no [hooks] and no [python] — so it
|
|
4
|
+
# carries zero executable behavior and is byte-identical to "no plugin" at run
|
|
5
|
+
# time. It exists as the canonical reference for the manifest format and as the
|
|
6
|
+
# fixture proving froid_loop/data/plugins/ ships in an installed wheel. A real
|
|
7
|
+
# plugin authoring walkthrough builds on this shape in the docs phase.
|
|
8
|
+
|
|
9
|
+
[plugin]
|
|
10
|
+
name = "example"
|
|
11
|
+
version = "1.0.0"
|
|
12
|
+
api_version = 1
|
|
13
|
+
description = "Data-only example plugin (settings only; no hooks, no code)."
|
|
14
|
+
author = "froid-loop"
|
|
15
|
+
|
|
16
|
+
[[settings]]
|
|
17
|
+
key = "greeting"
|
|
18
|
+
type = "str"
|
|
19
|
+
default = "hello"
|
|
20
|
+
label = "Greeting"
|
|
21
|
+
help = "Demonstration setting; a data-only plugin is always active, so this renders in the settings UI."
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
# Bundled Test Architect Enterprise (TEA) plugin for froid-loop.
|
|
2
|
+
#
|
|
3
|
+
# Wires the FROID "Test Architect Enterprise" module (the "Murat" agent + its
|
|
4
|
+
# `froid-testarch-*` workflows) into every run and sweep as advisory-by-default
|
|
5
|
+
# quality steps. It assumes TEA is already installed in the project
|
|
6
|
+
# (`npx froid-plane install` -> Test Architect), and orchestrates by injecting
|
|
7
|
+
# natural-language sessions that invoke the relevant TEA workflow at two
|
|
8
|
+
# stages that have a live worktree:
|
|
9
|
+
#
|
|
10
|
+
# post_dev_phase test-design (TD) + atdd (AT) + automate (TA) [role=dev]
|
|
11
|
+
# pre_commit_gate trace (TR) + nfr (NR) + test-review (RV) [role=review]
|
|
12
|
+
#
|
|
13
|
+
# The gate steps bind to pre_commit_gate — the injection point that fires
|
|
14
|
+
# unconditionally just before every commit — NOT post_review_result, which fires
|
|
15
|
+
# only when the orchestrator review loop runs (review.trigger="recommended"
|
|
16
|
+
# skips it whenever the dev session doesn't recommend a follow-up). Gates
|
|
17
|
+
# therefore evaluate the exact tree about to commit, and the `*_blocking` flags
|
|
18
|
+
# enforce on every path.
|
|
19
|
+
#
|
|
20
|
+
# Because sweep bundles run the same inherited dev/review pipeline, these
|
|
21
|
+
# workflows fire for runs AND sweep bundles with no sweep-specific code.
|
|
22
|
+
#
|
|
23
|
+
# Opt in from .froid-loop/policy.toml:
|
|
24
|
+
# [plugins]
|
|
25
|
+
# enabled = ["tea"] # [python] readiness gate loads only when enabled
|
|
26
|
+
#
|
|
27
|
+
# [plugins.tea]
|
|
28
|
+
# # every step is on + advisory by default; flip what you need:
|
|
29
|
+
# nfr_blocking = true # escalate (don't commit) when the NFR gate fails
|
|
30
|
+
# atdd_enabled = false # skip the ATDD generation step
|
|
31
|
+
#
|
|
32
|
+
# The per-step `<name>_enabled` / `<name>_blocking` settings use the generic
|
|
33
|
+
# plugin-system workflow-overlay convention (see docs/plugin-authoring-guide.md,
|
|
34
|
+
# "Making a workflow configurable"). Generation steps (td/atdd/automate) ship
|
|
35
|
+
# advisory; the gate steps operators are likely to enforce (trace/nfr/review)
|
|
36
|
+
# expose a `*_blocking` flag — when true, a FAIL/CONCERNS gate verdict escalates
|
|
37
|
+
# the unit at commit time (enforcement: `TeaPlugin.on_pre_commit`).
|
|
38
|
+
|
|
39
|
+
[plugin]
|
|
40
|
+
name = "tea"
|
|
41
|
+
version = "1.0.0"
|
|
42
|
+
api_version = 1
|
|
43
|
+
description = "Inject the FROID Test Architect Enterprise (TEA) workflows as advisory-by-default quality steps across runs and sweeps."
|
|
44
|
+
author = "froid-loop"
|
|
45
|
+
# TEA's runtime + its compiled per-CLI skills are gitignored / not guaranteed in
|
|
46
|
+
# a fresh checkout, so prime them into an isolated per-worktree checkout. No-ops
|
|
47
|
+
# under today's in-place (isolation = none) mode; needed once worktrees are used.
|
|
48
|
+
seed_globs = [
|
|
49
|
+
"_froid/**", # TEA runtime: config.yaml + testarch workflows + knowledge base
|
|
50
|
+
".claude/skills/froid-t*", # compiled TEA skills for claude-code (froid-tea + froid-testarch-*)
|
|
51
|
+
".agents/skills/froid-t*", # codex/cursor compiled skill home
|
|
52
|
+
".gemini/skills/froid-t*", # gemini compiled skill home
|
|
53
|
+
".agent/skills/froid-t*", # generic agent compiled skill home
|
|
54
|
+
]
|
|
55
|
+
|
|
56
|
+
# In-process module. Trust-gated: never imported unless "tea" is in
|
|
57
|
+
# [plugins] enabled. Owns the readiness gate (validate) and the blocking-gate
|
|
58
|
+
# enforcement at pre_commit (on_pre_commit).
|
|
59
|
+
[python]
|
|
60
|
+
module = "tea_plugin.py"
|
|
61
|
+
class = "TeaPlugin"
|
|
62
|
+
|
|
63
|
+
# ----------------------------------------------------------------- settings
|
|
64
|
+
|
|
65
|
+
# Readiness gate. With this on (default), a run fails fast at startup if TEA is
|
|
66
|
+
# not installed in the project; turn it off to let the workflows degrade to
|
|
67
|
+
# advisory prompts even when TEA is absent.
|
|
68
|
+
[[settings]]
|
|
69
|
+
key = "require_tea"
|
|
70
|
+
type = "bool"
|
|
71
|
+
default = true
|
|
72
|
+
label = "Require TEA installed"
|
|
73
|
+
help = "Fail fast at startup if the project has no _froid/tea/config.yaml (TEA not installed). false = run anyway."
|
|
74
|
+
|
|
75
|
+
# Per-step enable flags (workflow-overlay convention: <name>_enabled). Every
|
|
76
|
+
# step ships on; set one false to drop just that workflow.
|
|
77
|
+
[[settings]]
|
|
78
|
+
key = "td_enabled"
|
|
79
|
+
type = "bool"
|
|
80
|
+
default = true
|
|
81
|
+
label = "Enable test-design (TD)"
|
|
82
|
+
help = "post_dev_phase: risk assessment + coverage strategy for the changes (froid-testarch-test-design)."
|
|
83
|
+
|
|
84
|
+
[[settings]]
|
|
85
|
+
key = "atdd_enabled"
|
|
86
|
+
type = "bool"
|
|
87
|
+
default = true
|
|
88
|
+
label = "Enable ATDD (AT)"
|
|
89
|
+
help = "post_dev_phase: failing acceptance tests + implementation checklist (froid-testarch-atdd)."
|
|
90
|
+
|
|
91
|
+
[[settings]]
|
|
92
|
+
key = "automate_enabled"
|
|
93
|
+
type = "bool"
|
|
94
|
+
default = true
|
|
95
|
+
label = "Enable automate (TA)"
|
|
96
|
+
help = "post_dev_phase: prioritized API/E2E tests, fixtures, DoD summary (froid-testarch-automate)."
|
|
97
|
+
|
|
98
|
+
[[settings]]
|
|
99
|
+
key = "trace_enabled"
|
|
100
|
+
type = "bool"
|
|
101
|
+
default = true
|
|
102
|
+
label = "Enable trace (TR)"
|
|
103
|
+
help = "pre_commit_gate: map requirements to tests + record the quality-gate decision (froid-testarch-trace)."
|
|
104
|
+
|
|
105
|
+
[[settings]]
|
|
106
|
+
key = "nfr_enabled"
|
|
107
|
+
type = "bool"
|
|
108
|
+
default = true
|
|
109
|
+
label = "Enable NFR (NR)"
|
|
110
|
+
help = "pre_commit_gate: assess non-functional requirements + record the gate decision (froid-testarch-nfr)."
|
|
111
|
+
|
|
112
|
+
[[settings]]
|
|
113
|
+
key = "review_enabled"
|
|
114
|
+
type = "bool"
|
|
115
|
+
default = true
|
|
116
|
+
label = "Enable test-review (RV)"
|
|
117
|
+
help = "pre_commit_gate: quality check against the written tests (froid-testarch-test-review)."
|
|
118
|
+
|
|
119
|
+
# Per-gate blocking flags (workflow-overlay convention: <name>_blocking). The
|
|
120
|
+
# gate steps ship advisory; flip one true so a FAIL/CONCERNS verdict escalates
|
|
121
|
+
# the unit at commit instead of landing (enforcement: TeaPlugin.on_pre_commit).
|
|
122
|
+
# Generation steps stay advisory by design, so they expose no flag.
|
|
123
|
+
[[settings]]
|
|
124
|
+
key = "trace_blocking"
|
|
125
|
+
type = "bool"
|
|
126
|
+
default = false
|
|
127
|
+
label = "Block on trace gate"
|
|
128
|
+
help = "true = a failing trace (requirements-traceability) gate escalates the unit at commit instead of committing."
|
|
129
|
+
|
|
130
|
+
[[settings]]
|
|
131
|
+
key = "nfr_blocking"
|
|
132
|
+
type = "bool"
|
|
133
|
+
default = false
|
|
134
|
+
label = "Block on NFR gate"
|
|
135
|
+
help = "true = a failing NFR gate escalates the unit at commit instead of committing."
|
|
136
|
+
|
|
137
|
+
[[settings]]
|
|
138
|
+
key = "review_blocking"
|
|
139
|
+
type = "bool"
|
|
140
|
+
default = false
|
|
141
|
+
label = "Block on test-review gate"
|
|
142
|
+
help = "true = a failing test-review gate escalates the unit at commit instead of committing."
|
|
143
|
+
|
|
144
|
+
# ---------------------------------------------------------------- workflows
|
|
145
|
+
|
|
146
|
+
# Prompts are CLI-agnostic: a natural-language intent plus the explicit TEA
|
|
147
|
+
# workflow/skill name, phrased around the changes in the working tree for
|
|
148
|
+
# {story_key} so they work for a single story AND a sweep bundle.
|
|
149
|
+
|
|
150
|
+
[workflows.td]
|
|
151
|
+
stage = "post_dev_phase"
|
|
152
|
+
role = "dev"
|
|
153
|
+
prompt = "Run the TEA Test Design workflow (skill `froid-testarch-test-design`) for `{story_key}`. Produce a risk assessment and a risk-based coverage strategy for the changes currently in the working tree, and write the artifacts under TEA's configured test_artifacts directory. Do not modify production code."
|
|
154
|
+
blocking = false
|
|
155
|
+
|
|
156
|
+
[workflows.atdd]
|
|
157
|
+
stage = "post_dev_phase"
|
|
158
|
+
role = "dev"
|
|
159
|
+
prompt = "Run the TEA ATDD workflow (skill `froid-testarch-atdd`) for `{story_key}`. Generate failing acceptance tests plus an implementation checklist covering the changes currently in the working tree, written under TEA's configured test_artifacts directory."
|
|
160
|
+
blocking = false
|
|
161
|
+
|
|
162
|
+
[workflows.automate]
|
|
163
|
+
stage = "post_dev_phase"
|
|
164
|
+
role = "dev"
|
|
165
|
+
prompt = "Run the TEA Test Automation workflow (skill `froid-testarch-automate`) for `{story_key}`. Generate prioritized API/E2E tests and fixtures for the changes currently in the working tree, plus a Definition-of-Done summary, under TEA's configured test_artifacts directory."
|
|
166
|
+
blocking = false
|
|
167
|
+
|
|
168
|
+
[workflows.trace]
|
|
169
|
+
stage = "pre_commit_gate"
|
|
170
|
+
role = "review"
|
|
171
|
+
prompt = "Run the TEA Trace Requirements workflow (skill `froid-testarch-trace`) for `{story_key}`. Map the requirements to the tests covering the changes currently in the working tree and record the quality-gate decision under TEA's configured test_artifacts directory."
|
|
172
|
+
blocking = false
|
|
173
|
+
|
|
174
|
+
[workflows.nfr]
|
|
175
|
+
stage = "pre_commit_gate"
|
|
176
|
+
role = "review"
|
|
177
|
+
prompt = "Run the TEA Non-Functional Requirements workflow (skill `froid-testarch-nfr`) for `{story_key}`. Assess the NFRs of the changes currently in the working tree and record the gate decision under TEA's configured test_artifacts directory."
|
|
178
|
+
blocking = false
|
|
179
|
+
|
|
180
|
+
[workflows.review]
|
|
181
|
+
stage = "pre_commit_gate"
|
|
182
|
+
role = "review"
|
|
183
|
+
prompt = "Run the TEA Test Review workflow (skill `froid-testarch-test-review`) for `{story_key}`. Perform a quality check against the tests covering the changes currently in the working tree, using TEA's knowledge base and best practices, and record findings under TEA's configured test_artifacts directory."
|
|
184
|
+
blocking = false
|