gooseloop 0.1.0__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.
gooseloop/extract.py ADDED
@@ -0,0 +1,200 @@
1
+ """Extract a JSON deliverable from LLM output.
2
+
3
+ LLMs are non-deterministic emitters. A recipe that says "wrap the payload
4
+ in <<<DELIVERABLE_JSON>>> ... <<<END_DELIVERABLE>>>" will, against a
5
+ weaker or just-creatively-paraphrasing model, get back ``<<<DELIMITED_JSON>>>``
6
+ or a markdown ```json fence`` or any other "obviously a wrapped JSON
7
+ deliverable" shape. The strict canonical-only parser throws away
8
+ parseable responses for vocabulary mismatches; the legacy "any balanced
9
+ object anywhere" fallback silently surfaces intermediate scratch dicts.
10
+
11
+ Both are wrong. The right shape is a small ordered list of wrapper
12
+ recognizers. Each one answers: "is there a JSON payload wrapped by
13
+ *something* that looks like a deliverable marker?" Tried in order from
14
+ most-specific to most-permissive; the first one that yields a parseable
15
+ balanced object wins. Bare JSON in prose still fails — every recognizer
16
+ requires *some* wrapper, so the "scattered intermediate dict" failure
17
+ mode the legacy fallback enabled stays dead.
18
+
19
+ Provenance is preserved on the way out. The looper logs (and surfaces
20
+ as an operator action) when a non-canonical recognizer matched, so the
21
+ operator can tighten the recipe at their own pace without the framework
22
+ silently absorbing drift.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import json
28
+ import re
29
+ from dataclasses import dataclass
30
+ from typing import Callable, Optional
31
+
32
+ from .text import strip_ansi
33
+
34
+
35
+ # Canonical wrapper. Recipes the framework ships use this verbatim;
36
+ # other recognizers exist for tolerance, not to encourage drift.
37
+ DELIVERABLE_START = "<<<DELIVERABLE_JSON>>>"
38
+ DELIVERABLE_END = "<<<END_DELIVERABLE>>>"
39
+
40
+
41
+ @dataclass(frozen=True)
42
+ class Extracted:
43
+ """A parsed deliverable plus which recognizer matched."""
44
+ payload: dict
45
+ recognizer: str # "canonical" | "angle_sentinel" | "markdown_fence"
46
+
47
+ @property
48
+ def is_canonical(self) -> bool:
49
+ return self.recognizer == "canonical"
50
+
51
+
52
+ # --- recognizers ---------------------------------------------------
53
+ #
54
+ # Each recognizer takes ANSI-stripped text and returns the candidate
55
+ # payload string (the bit between wrapper markers) or None if its
56
+ # wrapper shape isn't present. extract_json then runs the balanced-
57
+ # object scan over the candidate; an unparseable candidate falls
58
+ # through to the next recognizer.
59
+
60
+ WrapperRecognizer = Callable[[str], Optional[str]]
61
+
62
+
63
+ def _canonical_sentinel(text: str) -> Optional[str]:
64
+ """Exact <<<DELIVERABLE_JSON>>> ... <<<END_DELIVERABLE>>>. Last
65
+ occurrence wins (models often echo the spec earlier in their prose,
66
+ then emit the real deliverable at the bottom)."""
67
+ start = text.rfind(DELIVERABLE_START)
68
+ if start == -1:
69
+ return None
70
+ payload_start = start + len(DELIVERABLE_START)
71
+ end = text.find(DELIVERABLE_END, payload_start)
72
+ return text[payload_start:end] if end != -1 else text[payload_start:]
73
+
74
+
75
+ _ANGLE_TAG_RE = re.compile(r"<<<\s*([A-Z][A-Z0-9_]*)\s*>>>")
76
+ _PAYLOAD_WORDS = ("JSON", "DELIVERABLE", "PAYLOAD", "OUTPUT", "RESULT")
77
+
78
+
79
+ def _angle_sentinel(text: str) -> Optional[str]:
80
+ """Generic <<<TAG>>> wrapper where TAG looks like a payload marker.
81
+
82
+ Catches DELIMITED_JSON, JSON_BEGIN, DELIVERABLE_PAYLOAD, OUTPUT_JSON,
83
+ and anything else triple-angle-wrapped with a JSON/DELIVERABLE/PAYLOAD/
84
+ OUTPUT/RESULT word in the tag. The last matching opener wins. We
85
+ don't try to match a specific close tag — the balanced-object scan
86
+ in extract_json handles trailing close markers as ignorable suffix.
87
+ """
88
+ matches = list(_ANGLE_TAG_RE.finditer(text))
89
+ if not matches:
90
+ return None
91
+ openers = [
92
+ m for m in matches
93
+ if "END" not in m.group(1).split("_")
94
+ and any(w in m.group(1) for w in _PAYLOAD_WORDS)
95
+ ]
96
+ if not openers:
97
+ return None
98
+ return text[openers[-1].end():]
99
+
100
+
101
+ _FENCE_RE = re.compile(r"```(?:json)?\s*\n?(.*?)\n?```", re.DOTALL | re.IGNORECASE)
102
+
103
+
104
+ def _markdown_fence(text: str) -> Optional[str]:
105
+ """Markdown code fence: ```json ... ``` or bare ``` ... ```. Last fence wins.
106
+
107
+ The balanced-object scan filters out fences that contain code rather
108
+ than JSON, so we don't need to discriminate on the language tag.
109
+ """
110
+ matches = list(_FENCE_RE.finditer(text))
111
+ if not matches:
112
+ return None
113
+ return matches[-1].group(1)
114
+
115
+
116
+ # Tolerant recognizers — tried only when no canonical opener is present.
117
+ # Canonical match is a commitment from the model (it claimed to follow
118
+ # the contract); we honour the commitment by parsing-or-refusing the
119
+ # canonical payload rather than fishing for JSON elsewhere when the
120
+ # canonical payload turns out to be garbage.
121
+ _TOLERANT_RECOGNIZERS: list[tuple[str, WrapperRecognizer]] = [
122
+ ("angle_sentinel", _angle_sentinel),
123
+ ("markdown_fence", _markdown_fence),
124
+ ]
125
+
126
+
127
+ # --- public API ----------------------------------------------------
128
+
129
+ def extract_json_with_provenance(text: str) -> Optional[Extracted]:
130
+ """Parse the deliverable. Returns Extracted on success, None on refusal.
131
+
132
+ Two-stage dispatch:
133
+ 1. Canonical opener present → parse the canonical payload or refuse.
134
+ No fallthrough — the model committed to the canonical contract.
135
+ 2. No canonical opener → try tolerant recognizers in order;
136
+ the first one yielding a parseable dict wins.
137
+ """
138
+ clean = strip_ansi(text)
139
+
140
+ if DELIVERABLE_START in clean:
141
+ payload = _try_parse(_canonical_sentinel(clean))
142
+ return Extracted(payload=payload, recognizer="canonical") if payload else None
143
+
144
+ for name, recognizer in _TOLERANT_RECOGNIZERS:
145
+ payload = _try_parse(recognizer(clean))
146
+ if payload is not None:
147
+ return Extracted(payload=payload, recognizer=name)
148
+ return None
149
+
150
+
151
+ def _try_parse(candidate: Optional[str]) -> Optional[dict]:
152
+ if candidate is None:
153
+ return None
154
+ balanced = _first_balanced_object(candidate)
155
+ if balanced is None:
156
+ return None
157
+ try:
158
+ payload = json.loads(balanced)
159
+ except json.JSONDecodeError:
160
+ return None
161
+ return payload if isinstance(payload, dict) else None
162
+
163
+
164
+ def extract_json(text: str) -> Optional[dict]:
165
+ """Backwards-compatible thin wrapper for callers that don't need provenance."""
166
+ result = extract_json_with_provenance(text)
167
+ return result.payload if result else None
168
+
169
+
170
+ # --- balanced-object scan -----------------------------------------
171
+
172
+ def _first_balanced_object(text: str) -> Optional[str]:
173
+ """Return the first balanced {...} substring, respecting string literals.
174
+
175
+ Lives here, not in text.py, because its only consumer is the JSON
176
+ extractor. Pure: depends on nothing else in the package.
177
+ """
178
+ start = text.find("{")
179
+ if start == -1:
180
+ return None
181
+ brace_count = 0
182
+ in_string = False
183
+ escape = False
184
+ for j, ch in enumerate(text[start:], start=start):
185
+ if escape:
186
+ escape = False
187
+ continue
188
+ if ch == "\\":
189
+ escape = True
190
+ continue
191
+ if ch == '"' and not escape:
192
+ in_string = not in_string
193
+ if not in_string:
194
+ if ch == "{":
195
+ brace_count += 1
196
+ elif ch == "}":
197
+ brace_count -= 1
198
+ if brace_count == 0:
199
+ return text[start:j + 1]
200
+ return None
gooseloop/footer.py ADDED
@@ -0,0 +1,75 @@
1
+ """Footers: per-call summary line and per-session multi-line wrap-up."""
2
+
3
+ from pathlib import Path
4
+
5
+ from .text import Color, colored
6
+
7
+
8
+ def _fmt_duration(seconds: float) -> str:
9
+ """e.g. 47s, 1m 23s, 12m 5s."""
10
+ s = int(round(seconds))
11
+ if s < 60:
12
+ return f"{s}s"
13
+ return f"{s // 60}m {s % 60}s"
14
+
15
+
16
+ def print_call_footer(label: str, elapsed: float, shell_calls: int,
17
+ retries: int, status: str = "ok") -> None:
18
+ """One-line footer after a single goose call."""
19
+ parts = [
20
+ label,
21
+ _fmt_duration(elapsed),
22
+ f"{shell_calls} shell",
23
+ ]
24
+ if retries:
25
+ parts.append(f"{retries} retr{'y' if retries == 1 else 'ies'}")
26
+ parts.append(status)
27
+ line = " ─ " + " · ".join(parts) + " ─"
28
+
29
+ color = Color.GREEN if status in ("ok", "skipped") else Color.RED
30
+ print(colored(line, color))
31
+
32
+
33
+ def print_session_footer(elapsed: float,
34
+ goose_calls: int,
35
+ actions_planned: int,
36
+ actions_ran: int,
37
+ actions_skipped: int,
38
+ outputs: list[str],
39
+ operator_actions: list[dict] | None = None,
40
+ session_dir: Path | None = None) -> None:
41
+ """Multi-line summary at end of a begin_loop() pass."""
42
+ title = f"═══ session complete · {_fmt_duration(elapsed)} ═══"
43
+ print()
44
+ print(colored(title, Color.CYAN))
45
+
46
+ rows: list[tuple[str, str]] = [
47
+ ("goose calls", str(goose_calls)),
48
+ ("actions", f"{actions_planned} planned · {actions_ran} ran · {actions_skipped} skipped"),
49
+ ]
50
+ label_width = max(len(k) for k, _ in rows)
51
+ for k, v in rows:
52
+ print(colored(f" {k:<{label_width}} {v}", Color.CYAN))
53
+
54
+ if outputs or session_dir:
55
+ print(colored(f" {'outputs':<{label_width}}", Color.CYAN))
56
+ if session_dir:
57
+ print(colored(f" {' ' * label_width} {session_dir}/", Color.CYAN))
58
+ for out in outputs:
59
+ print(colored(f" {' ' * label_width} {out}", Color.CYAN))
60
+
61
+ if operator_actions:
62
+ print(colored(f" {'operator':<{label_width}}", Color.YELLOW))
63
+ for entry in operator_actions:
64
+ action = entry.get("action", "(no action)")
65
+ why = entry.get("why", "")
66
+ print(colored(f" {' ' * label_width} - {action}", Color.YELLOW))
67
+ if why:
68
+ print(colored(f" {' ' * label_width} why: {why}", Color.YELLOW))
69
+ print()
70
+
71
+
72
+ def recipe_label(recipe_path: str, suffix: str | None = None) -> str:
73
+ """Compact label for a recipe path. 'recipes/to-outreach.yaml' -> 'to-outreach'."""
74
+ name = Path(recipe_path).stem
75
+ return f"{name}[{suffix}]" if suffix else name
gooseloop/goose.py ADDED
@@ -0,0 +1,340 @@
1
+ """Goose CLI invocation: subprocess wrapper with retry and rate-limit handling.
2
+
3
+ Pure execution layer. Knows about goose, OpenRouter-style rate-limit messages,
4
+ and transient errors. Knows nothing about Phases, engines, or sessions.
5
+ """
6
+
7
+ import contextlib
8
+ import os
9
+ import re
10
+ import subprocess
11
+ import sys
12
+ import time
13
+ from pathlib import Path
14
+ from typing import Any, Callable, Optional
15
+
16
+ import yaml
17
+
18
+ from .context_prepend import _NO_FOLD_WIDTH, _BlockStyleDumper, render_recipe_with_context
19
+ from .footer import print_call_footer, recipe_label
20
+ from .recipe_merge import load_layered_recipe
21
+ from .text import Color, colored
22
+
23
+
24
+ # Per-minute rate-limit windows reset every 60s. 65s gives a 5s buffer so the
25
+ # next request lands clearly inside the new window.
26
+ RATE_LIMIT_WAIT_SECONDS = 65
27
+
28
+
29
+ _RATE_LIMIT_LINE_RE = re.compile(
30
+ r"^[ \t]*(rate limit|429\b|too many requests)",
31
+ re.MULTILINE | re.IGNORECASE,
32
+ )
33
+
34
+ _TRANSIENT_ERROR_LINE_RE = re.compile(
35
+ # Line-start anchored. Keeps the model's mid-prose narration
36
+ # ("I'll handle server errors here") from tripping the regex.
37
+ r"^[ \t]*("
38
+ r"server error\b"
39
+ r"|provider returned error\b"
40
+ r"|5\d{2}\s+(internal|service|gateway|bad|server|error)"
41
+ r"|error[: ]+5\d{2}\b"
42
+ r"|connection (refused|reset|timed? out)"
43
+ r")",
44
+ re.MULTILINE | re.IGNORECASE,
45
+ )
46
+
47
+ # Stream-level decode failures: goose's HTTP client choked mid-response
48
+ # (network blip, provider truncation, partial chunked stream). Real
49
+ # observed wording from 2026-06-04: "Ran into this error: Request
50
+ # failed: Stream decode error: error decoding response body." The
51
+ # prefix "Ran into this error:" means line-start anchoring won't catch
52
+ # it — these patterns are specific enough that mid-line search is safe
53
+ # (a model isn't going to monologue "stream decode error" by accident).
54
+ _STREAM_DECODE_RE = re.compile(
55
+ r"("
56
+ r"stream decode error"
57
+ r"|error decoding response body"
58
+ r"|request failed:.*decode"
59
+ r")",
60
+ re.IGNORECASE,
61
+ )
62
+
63
+ # Persistent failure modes — retrying won't help. Match these before
64
+ # the transient check so we fail-fast instead of burning max_retries
65
+ # waiting for a provider/model combination that fundamentally can't
66
+ # complete the task.
67
+ #
68
+ # - "filtered for safety": openrouter's safety filter is stripping
69
+ # tool-call payloads (seen with owl-alpha/LongCat).
70
+ # - "tool response was empty": goose received nothing parseable back.
71
+ # - "tool output is being processed by the underlying provider library":
72
+ # provider-side post-processing swallowed the response.
73
+ # - "Invalid recipe": goose couldn't parse the recipe at all (e.g. a
74
+ # MiniJinja template syntax error). The recipe bytes are fixed for the
75
+ # run, so every retry re-parses the same file and fails identically —
76
+ # retrying is pure waste, and the operator needs to see the error now,
77
+ # not after 6 backoffs. (2026-06-05: a context-injected recap broke the
78
+ # summary recipe's template and burned the full retry budget.)
79
+ _PERSISTENT_FAILURE_RE = re.compile(
80
+ r"("
81
+ r"filtered for safety"
82
+ r"|tool response was empty"
83
+ r"|tool output is being processed by the underlying provider library"
84
+ r"|Invalid recipe"
85
+ r")",
86
+ re.IGNORECASE,
87
+ )
88
+
89
+
90
+ def _is_rate_limit(output: str) -> bool:
91
+ return bool(_RATE_LIMIT_LINE_RE.search(output))
92
+
93
+
94
+ def _is_persistent_failure(output: str) -> bool:
95
+ """Failures that won't get better with retry. Caller should fail fast."""
96
+ return bool(_PERSISTENT_FAILURE_RE.search(output))
97
+
98
+
99
+ def _is_recipe_error(output: str) -> bool:
100
+ """A recipe-parse failure specifically (vs a provider/model failure).
101
+
102
+ Deterministic in the recipe bytes, so it's both persistent AND the
103
+ operator's to fix — worth a distinct, actionable message."""
104
+ return "invalid recipe" in output.lower()
105
+
106
+
107
+ def _first_recipe_error_line(output: str) -> str | None:
108
+ """The goose line carrying the recipe-parse error, for the fail-fast
109
+ message — so the operator sees `Invalid recipe: syntax error: ...`
110
+ instead of having to scroll back through the streamed output."""
111
+ for line in output.splitlines():
112
+ if "invalid recipe" in line.lower():
113
+ return line.strip()
114
+ return None
115
+
116
+
117
+ def _is_transient_error(output: str, returncode: int) -> bool:
118
+ if returncode != 0:
119
+ return True
120
+ if _TRANSIENT_ERROR_LINE_RE.search(output):
121
+ return True
122
+ if _STREAM_DECODE_RE.search(output):
123
+ return True
124
+ if _is_rate_limit(output):
125
+ return True
126
+ return False
127
+
128
+
129
+ def _countdown_sleep(seconds: int, header: str, color: str | None = None) -> None:
130
+ color = color or Color.YELLOW
131
+ if not sys.stdout.isatty():
132
+ print(colored(f" {header} — waiting {seconds}s", color))
133
+ time.sleep(seconds)
134
+ return
135
+
136
+ width = max(50, len(header) + 6)
137
+ top = "┌" + "─" * (width - 2) + "┐"
138
+ middle = "│ " + header.ljust(width - 4) + "│"
139
+ bottom = "└" + "─" * (width - 2) + "┘"
140
+ print(colored(top, color))
141
+ print(colored(middle, color))
142
+ print(colored(bottom, color))
143
+
144
+ bar_width = 30
145
+ line_pad = bar_width + 40
146
+
147
+ try:
148
+ for elapsed in range(seconds):
149
+ remaining = seconds - elapsed
150
+ filled = int(elapsed / seconds * bar_width)
151
+ bar = "▰" * filled + "▱" * (bar_width - filled)
152
+ line = f" retrying in {remaining:>3}s {bar}"
153
+ sys.stdout.write(f"\r{colored(line, color)}")
154
+ sys.stdout.flush()
155
+ time.sleep(1)
156
+ except KeyboardInterrupt:
157
+ sys.stdout.write("\r" + " " * line_pad + "\r")
158
+ sys.stdout.flush()
159
+ raise
160
+
161
+ sys.stdout.write("\r" + " " * line_pad + "\r")
162
+ sys.stdout.flush()
163
+ print(colored(" retrying now...", Color.GREEN))
164
+
165
+
166
+ def _run_goose_internal(recipe_path: str, model: str,
167
+ extra_env: dict | None = None) -> tuple[str, int]:
168
+ env = os.environ.copy()
169
+ if extra_env:
170
+ env.update(extra_env)
171
+
172
+ cmd = ["goose", "run", "--recipe", recipe_path, "--model", model]
173
+ proc = subprocess.Popen(
174
+ cmd,
175
+ stdout=subprocess.PIPE,
176
+ stderr=subprocess.STDOUT,
177
+ text=True,
178
+ env=env,
179
+ bufsize=1,
180
+ )
181
+ lines = []
182
+ try:
183
+ assert proc.stdout is not None
184
+ for line in proc.stdout:
185
+ sys.stdout.write(line)
186
+ sys.stdout.flush()
187
+ lines.append(line)
188
+ except KeyboardInterrupt:
189
+ proc.terminate()
190
+ raise
191
+ proc.wait()
192
+ return "".join(lines), proc.returncode
193
+
194
+
195
+ def count_shell_calls(output: str) -> int:
196
+ """Goose marks each shell tool call with '▸ shell' in its streamed output."""
197
+ return output.count("▸ shell")
198
+
199
+
200
+ @contextlib.contextmanager
201
+ def _prepared_recipe(recipe_path: Path,
202
+ extra_env: dict | None,
203
+ *,
204
+ environment: Any = None,
205
+ local_path: Path | None = None,
206
+ overlay_paths: list[Path] | None = None):
207
+ """Yield the effective recipe path: overlay-merged + context-block rendered.
208
+
209
+ Steps:
210
+ 1. Merge base + local + CLI overlays into one dict (recipe_merge).
211
+ 2. Resolve the context: block to literal text (context_prepend).
212
+ 3. Write a temp YAML file goose can read; yield its path.
213
+
214
+ Cleanup happens on context exit unless GOOSER_KEEP_RENDERED=1 is set.
215
+ """
216
+ merged = load_layered_recipe(
217
+ recipe_path,
218
+ local_path=local_path,
219
+ overlay_paths=overlay_paths,
220
+ )
221
+ rendered = render_recipe_with_context(merged, extra_env or {}, environment=environment)
222
+ if rendered is None:
223
+ # No context: block — write the merged dict to a temp file so any
224
+ # overlay changes still reach goose.
225
+ import tempfile
226
+ tmp = tempfile.NamedTemporaryFile(
227
+ mode="w", suffix=".merged.yaml", delete=False,
228
+ )
229
+ yaml.dump(merged, tmp, Dumper=_BlockStyleDumper, sort_keys=False,
230
+ default_flow_style=False, allow_unicode=True, width=_NO_FOLD_WIDTH)
231
+ tmp.close()
232
+ rendered = tmp.name
233
+ try:
234
+ yield rendered
235
+ finally:
236
+ if not os.environ.get("GOOSER_KEEP_RENDERED"):
237
+ try:
238
+ os.unlink(rendered)
239
+ except OSError:
240
+ pass
241
+
242
+
243
+ def run_goose_with_retry(
244
+ recipe_path: str,
245
+ model: str,
246
+ extra_env: dict | None = None,
247
+ *,
248
+ max_retries: int = 6,
249
+ base_delay: int = 5,
250
+ success_predicate: Optional[Callable[[str], bool]] = None,
251
+ label: str | None = None,
252
+ environment: Any = None,
253
+ local_path: Path | None = None,
254
+ overlay_paths: list[Path] | None = None,
255
+ ) -> str:
256
+ """Run goose with automatic retry on transient errors.
257
+
258
+ Rate-limit errors wait RATE_LIMIT_WAIT_SECONDS (65s). Other transient
259
+ errors use base_delay * (attempt+1) backoff. `success_predicate(output)`
260
+ lets stdout-deliverable recipes keep usable output when a trailing
261
+ rate-limit message follows the real result.
262
+
263
+ Raises RuntimeError if all retries exhausted.
264
+ """
265
+ start = time.perf_counter()
266
+ retries_used = 0
267
+ final_output: str | None = None
268
+
269
+ with _prepared_recipe(
270
+ Path(recipe_path), extra_env,
271
+ environment=environment,
272
+ local_path=local_path,
273
+ overlay_paths=overlay_paths,
274
+ ) as effective_path:
275
+ for attempt in range(max_retries):
276
+ output, returncode = _run_goose_internal(effective_path, model, extra_env)
277
+
278
+ # Persistent failure shortcuts the retry loop: a provider that
279
+ # filters tool calls or a model that can't speak goose's tool
280
+ # protocol will not improve with another attempt. Fail fast
281
+ # rather than burn max_retries on something structurally broken.
282
+ if _is_persistent_failure(output):
283
+ if _is_recipe_error(output):
284
+ reason = (
285
+ "Recipe failed to parse; not retrying (the recipe bytes "
286
+ "are identical every attempt). Fix the recipe/template:"
287
+ )
288
+ else:
289
+ reason = (
290
+ "Persistent provider/model failure detected; not retrying "
291
+ "(recipe + model + provider combination appears incompatible)."
292
+ )
293
+ print(colored(reason, Color.RED), file=sys.stderr)
294
+ detail = _first_recipe_error_line(output)
295
+ if detail:
296
+ print(colored(f" {detail}", Color.RED), file=sys.stderr)
297
+ break
298
+
299
+ if success_predicate is not None:
300
+ success = success_predicate(output)
301
+ else:
302
+ success = not _is_transient_error(output, returncode)
303
+
304
+ if success:
305
+ final_output = output
306
+ break
307
+
308
+ retries_used += 1
309
+ if _is_rate_limit(output):
310
+ delay = RATE_LIMIT_WAIT_SECONDS
311
+ header = f"Rate limit hit · attempt {attempt + 1}/{max_retries}"
312
+ color = Color.YELLOW
313
+ else:
314
+ delay = base_delay * (attempt + 1)
315
+ header = f"Transient error · attempt {attempt + 1}/{max_retries}"
316
+ color = Color.MAGENTA
317
+ _countdown_sleep(delay, header, color=color)
318
+
319
+ if final_output is None:
320
+ elapsed = time.perf_counter() - start
321
+ print_call_footer(
322
+ label or recipe_label(recipe_path),
323
+ elapsed=elapsed, shell_calls=0,
324
+ retries=retries_used, status="failed",
325
+ )
326
+ attempts_desc = (
327
+ "without retrying (persistent failure)" if retries_used == 0
328
+ else f"after {retries_used} retr{'y' if retries_used == 1 else 'ies'}"
329
+ )
330
+ raise RuntimeError(f"goose failed {attempts_desc}: {recipe_path}")
331
+
332
+ elapsed = time.perf_counter() - start
333
+ print_call_footer(
334
+ label or recipe_label(recipe_path),
335
+ elapsed=elapsed,
336
+ shell_calls=count_shell_calls(final_output),
337
+ retries=retries_used,
338
+ status="ok",
339
+ )
340
+ return final_output