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