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/__init__.py +42 -0
- gooseloop/__main__.py +181 -0
- gooseloop/artifact.py +82 -0
- gooseloop/branch_policy.py +49 -0
- gooseloop/config.py +104 -0
- gooseloop/context_prepend.py +316 -0
- gooseloop/contrib/__init__.py +18 -0
- gooseloop/contrib/claude_handoff.py +74 -0
- gooseloop/contrib/customer_pipeline.py +98 -0
- gooseloop/engine.py +68 -0
- gooseloop/environment.py +31 -0
- gooseloop/extract.py +200 -0
- gooseloop/footer.py +75 -0
- gooseloop/goose.py +340 -0
- gooseloop/looper.py +581 -0
- gooseloop/phase.py +146 -0
- gooseloop/predicates.py +69 -0
- gooseloop/protocol.py +197 -0
- gooseloop/recipe_merge.py +152 -0
- gooseloop/session.py +41 -0
- gooseloop/text.py +60 -0
- gooseloop/toolkit.py +243 -0
- gooseloop-0.1.0.dist-info/METADATA +169 -0
- gooseloop-0.1.0.dist-info/RECORD +28 -0
- gooseloop-0.1.0.dist-info/WHEEL +5 -0
- gooseloop-0.1.0.dist-info/entry_points.txt +2 -0
- gooseloop-0.1.0.dist-info/licenses/LICENSE +21 -0
- gooseloop-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
"""Pre-render recipe `context:` blocks into the prompt before goose sees it.
|
|
2
|
+
|
|
3
|
+
Per PROTOCOL.md §7. Recipes declare load-bearing inputs under a top-level
|
|
4
|
+
`context:` block. Each entry is {label, source, optional?}. Before invoking
|
|
5
|
+
goose, the looper resolves each source to literal text and prepends a
|
|
6
|
+
fenced block to the recipe's `prompt`. The model cannot skip the step
|
|
7
|
+
because the text is in the prompt, not behind a bash call.
|
|
8
|
+
|
|
9
|
+
Sources:
|
|
10
|
+
env_file:VAR read the file whose path is in env var VAR
|
|
11
|
+
file:PATH read PATH directly (env-substituted)
|
|
12
|
+
glob:PATTERN glob (env-substituted), concat sorted matches
|
|
13
|
+
env_method:NAME call environment.NAME() and paste its return value
|
|
14
|
+
|
|
15
|
+
Failure is loud by default. `optional: true` softens to a sentinel
|
|
16
|
+
placeholder when a source is unresolvable.
|
|
17
|
+
|
|
18
|
+
If GOOSER_KEEP_RENDERED=1 the rendered recipe is left on disk for
|
|
19
|
+
inspection. Default is delete-after-run (cleanup is the caller's
|
|
20
|
+
responsibility).
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
import glob as _glob
|
|
24
|
+
import os
|
|
25
|
+
import re
|
|
26
|
+
import tempfile
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
from typing import Any, Optional
|
|
29
|
+
|
|
30
|
+
import yaml
|
|
31
|
+
|
|
32
|
+
from .toolkit import ZWSP as _ZWSP
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
_BLOCK_OPEN = "<<<CONTEXT: {label}>>>"
|
|
36
|
+
_BLOCK_CLOSE = "<<<END CONTEXT>>>"
|
|
37
|
+
|
|
38
|
+
_PREAMBLE = (
|
|
39
|
+
"# Pre-loaded input\n"
|
|
40
|
+
"#\n"
|
|
41
|
+
"# The looper has already resolved the input files this recipe\n"
|
|
42
|
+
"# requires and pasted their contents below. You do NOT need to\n"
|
|
43
|
+
"# `cat` any of these paths yourself - the literal contents are\n"
|
|
44
|
+
"# already in your context. Read the block, then follow the\n"
|
|
45
|
+
"# instructions that come after it.\n"
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
_ENV_VAR_RE = re.compile(r"\$\{([A-Za-z_][A-Za-z0-9_]*)\}|\$([A-Za-z_][A-Za-z0-9_]*)")
|
|
50
|
+
|
|
51
|
+
# A MiniJinja raw-block control token in any accepted spelling: {% raw %} /
|
|
52
|
+
# {% endraw %}, with optional `-` trim markers and arbitrary inner whitespace
|
|
53
|
+
# ({%- endraw -%}, {%raw%}, {% endraw %}). These are the ONE thing a raw
|
|
54
|
+
# block can't safely contain, so _raw_wrap defuses them.
|
|
55
|
+
_RAW_CTRL_RE = re.compile(r"\{%-?\s*(?:end)?raw\s*-?%\}")
|
|
56
|
+
|
|
57
|
+
# _ZWSP (imported from toolkit, the one home for the zero-width-space trick)
|
|
58
|
+
# is inserted between the `{` and `%` of a raw-control token so MiniJinja no
|
|
59
|
+
# longer sees a `{%` tag opener. The character is invisible, so the model
|
|
60
|
+
# reading the pasted text sees essentially the original; only the ephemeral
|
|
61
|
+
# rendered prompt is touched (the on-disk recap is never modified).
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _raw_wrap(text: str) -> str:
|
|
65
|
+
"""Wrap arbitrary text so goose's MiniJinja templater treats it as literal.
|
|
66
|
+
|
|
67
|
+
Goose renders each recipe `prompt` through MiniJinja (to resolve its own
|
|
68
|
+
`{{ param }}` placeholders) AFTER the looper has pasted context into the
|
|
69
|
+
prompt. Any `{{`, `{%`, or `{#` sitting in the pasted data is then parsed
|
|
70
|
+
as a Jinja tag: a recap that mentioned a Svelte `{#if}` opened a comment
|
|
71
|
+
that never closed and goose died with "unexpected end of comment". Wrapping
|
|
72
|
+
the data in {% raw %}…{% endraw %} neutralises every delimiter at once.
|
|
73
|
+
|
|
74
|
+
The one sequence a raw block cannot itself contain is a raw-control token
|
|
75
|
+
(`{% raw %}` / `{% endraw %}`) — a recap *of this very function* carries
|
|
76
|
+
them in its prose. Rather than try to re-emit those tokens (which needs a
|
|
77
|
+
`{{ "…" }}` expression whose safety depends on the exact MiniJinja version
|
|
78
|
+
goose ships — and goose's build rejects it), we break the `{%` opener with
|
|
79
|
+
a zero-width space so goose never recognises a tag. The result is exactly
|
|
80
|
+
one clean raw block per body: no nesting, no stray `{% endraw %}`, valid in
|
|
81
|
+
any MiniJinja that supports raw at all. Empty text is returned unwrapped."""
|
|
82
|
+
if not text:
|
|
83
|
+
return text
|
|
84
|
+
defused = _RAW_CTRL_RE.sub(lambda m: "{" + _ZWSP + m.group(0)[1:], text)
|
|
85
|
+
return "{% raw %}" + defused + "{% endraw %}"
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _prompt_needs_substitution(prompt: str) -> bool:
|
|
89
|
+
return isinstance(prompt, str) and bool(_ENV_VAR_RE.search(prompt))
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _substitute_env(template: str, env: dict[str, str]) -> str:
|
|
93
|
+
def repl(m: re.Match) -> str:
|
|
94
|
+
name = m.group(1) or m.group(2)
|
|
95
|
+
return env.get(name, "")
|
|
96
|
+
return _ENV_VAR_RE.sub(repl, template)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _resolve_env_file(arg: str, env: dict[str, str], *, optional: bool) -> str:
|
|
100
|
+
var_name = arg.strip()
|
|
101
|
+
path_str = env.get(var_name)
|
|
102
|
+
if not path_str:
|
|
103
|
+
if optional:
|
|
104
|
+
return f"(env var {var_name} is unset; skipped)"
|
|
105
|
+
raise RuntimeError(
|
|
106
|
+
f"context source 'env_file:{var_name}' failed: "
|
|
107
|
+
f"env var {var_name} is unset or empty"
|
|
108
|
+
)
|
|
109
|
+
path = Path(path_str)
|
|
110
|
+
if not path.exists():
|
|
111
|
+
if optional:
|
|
112
|
+
return f"(file not present: {path})"
|
|
113
|
+
raise RuntimeError(
|
|
114
|
+
f"context source 'env_file:{var_name}' failed: "
|
|
115
|
+
f"file does not exist: {path}"
|
|
116
|
+
)
|
|
117
|
+
return path.read_text()
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _resolve_file(arg: str, env: dict[str, str], *, optional: bool) -> str:
|
|
121
|
+
path_str = _substitute_env(arg.strip(), env)
|
|
122
|
+
if not path_str:
|
|
123
|
+
if optional:
|
|
124
|
+
return f"(file path resolved empty for '{arg}'; skipped)"
|
|
125
|
+
raise RuntimeError(
|
|
126
|
+
f"context source 'file:{arg}' resolved to an empty path"
|
|
127
|
+
)
|
|
128
|
+
path = Path(path_str)
|
|
129
|
+
if not path.exists():
|
|
130
|
+
if optional:
|
|
131
|
+
return f"(file not present: {path})"
|
|
132
|
+
raise RuntimeError(
|
|
133
|
+
f"context source 'file:{arg}' failed: file does not exist: {path}"
|
|
134
|
+
)
|
|
135
|
+
return path.read_text()
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _resolve_glob(arg: str, env: dict[str, str], *, optional: bool) -> str:
|
|
139
|
+
pattern = _substitute_env(arg.strip(), env)
|
|
140
|
+
if not pattern:
|
|
141
|
+
if optional:
|
|
142
|
+
return f"(glob pattern resolved empty for '{arg}'; skipped)"
|
|
143
|
+
raise RuntimeError(
|
|
144
|
+
f"context source 'glob:{arg}' resolved to an empty pattern"
|
|
145
|
+
)
|
|
146
|
+
matches = sorted(_glob.glob(pattern))
|
|
147
|
+
if not matches:
|
|
148
|
+
if optional:
|
|
149
|
+
return f"(no files matched pattern: {pattern})"
|
|
150
|
+
raise RuntimeError(
|
|
151
|
+
f"context source 'glob:{arg}' matched no files (pattern: {pattern})"
|
|
152
|
+
)
|
|
153
|
+
chunks = []
|
|
154
|
+
for path_str in matches:
|
|
155
|
+
path = Path(path_str)
|
|
156
|
+
chunks.append(f"=== {path.name} ===\n{path.read_text()}")
|
|
157
|
+
return "\n\n".join(chunks)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _resolve_env_method(arg: str, environment: Any) -> str:
|
|
161
|
+
method_name = arg.strip()
|
|
162
|
+
if environment is None:
|
|
163
|
+
raise RuntimeError(
|
|
164
|
+
f"context source 'env_method:{method_name}' requires an "
|
|
165
|
+
f"Environment instance; the Looper was constructed without one"
|
|
166
|
+
)
|
|
167
|
+
method = getattr(environment, method_name, None)
|
|
168
|
+
if method is None or not callable(method):
|
|
169
|
+
raise RuntimeError(
|
|
170
|
+
f"context source 'env_method:{method_name}' failed: "
|
|
171
|
+
f"{type(environment).__name__} has no callable named {method_name!r}"
|
|
172
|
+
)
|
|
173
|
+
result = method()
|
|
174
|
+
if not isinstance(result, str):
|
|
175
|
+
raise RuntimeError(
|
|
176
|
+
f"context source 'env_method:{method_name}' must return str, "
|
|
177
|
+
f"got {type(result).__name__}"
|
|
178
|
+
)
|
|
179
|
+
return result
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def substitute_env_in_prompt(doc: dict, env: dict[str, str]) -> dict:
|
|
183
|
+
"""Return a copy of `doc` with `${VAR}` / `$VAR` substituted in the prompt.
|
|
184
|
+
|
|
185
|
+
Goose itself does NOT shell-expand env vars inside recipe prompt prose
|
|
186
|
+
(its own templating uses `{{ name }}` parameters declared at the top
|
|
187
|
+
of the recipe). The looper substitutes here so recipes can reference
|
|
188
|
+
${POTENTIAL_DIR}, ${NAME}, etc. uniformly with the env vars the
|
|
189
|
+
Environment / engine inject. Substitution is non-destructive: an
|
|
190
|
+
unknown variable becomes an empty string and the next step (file read,
|
|
191
|
+
glob, etc.) raises a clear error there.
|
|
192
|
+
"""
|
|
193
|
+
prompt = doc.get("prompt")
|
|
194
|
+
if not isinstance(prompt, str) or not prompt:
|
|
195
|
+
return doc
|
|
196
|
+
return {**doc, "prompt": _substitute_env(prompt, env)}
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _resolve_source(source: str, env: dict[str, str], environment: Any, *,
|
|
200
|
+
optional: bool) -> str:
|
|
201
|
+
if ":" not in source:
|
|
202
|
+
raise RuntimeError(
|
|
203
|
+
f"context source {source!r} missing 'kind:' prefix "
|
|
204
|
+
f"(expected one of env_file, file, glob, env_method)"
|
|
205
|
+
)
|
|
206
|
+
kind, _, arg = source.partition(":")
|
|
207
|
+
kind = kind.strip()
|
|
208
|
+
if kind == "env_file":
|
|
209
|
+
return _resolve_env_file(arg, env, optional=optional)
|
|
210
|
+
if kind == "file":
|
|
211
|
+
return _resolve_file(arg, env, optional=optional)
|
|
212
|
+
if kind == "glob":
|
|
213
|
+
return _resolve_glob(arg, env, optional=optional)
|
|
214
|
+
if kind == "env_method":
|
|
215
|
+
return _resolve_env_method(arg, environment)
|
|
216
|
+
raise RuntimeError(
|
|
217
|
+
f"context source kind {kind!r} is not supported "
|
|
218
|
+
f"(expected env_file, file, glob, env_method)"
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
class _BlockStyleDumper(yaml.SafeDumper):
|
|
223
|
+
"""SafeDumper that emits multi-line strings as literal block scalars (`|`).
|
|
224
|
+
|
|
225
|
+
The default double-quoted folding splits long scalars at ~80 columns,
|
|
226
|
+
which can land a break in the middle of a goose template tag
|
|
227
|
+
(`{% raw %}` / `{% endraw %}`). goose's YAML parser reconstructs that
|
|
228
|
+
fold in a way that mangles the tag, so MiniJinja never sees the raw-block
|
|
229
|
+
terminator and fails with "unexpected end of raw block". A literal block
|
|
230
|
+
scalar preserves the prompt verbatim with no folding.
|
|
231
|
+
|
|
232
|
+
But literal style is not always available: PyYAML silently falls back to
|
|
233
|
+
double-quoted (and folds again) when a scalar has a line with trailing
|
|
234
|
+
whitespace, which pasted context routinely does (a config line like
|
|
235
|
+
`location_constraint = `). So the literal representer is only half the fix;
|
|
236
|
+
the guarantee is `width` on the dump call below set high enough that no
|
|
237
|
+
scalar folds in EITHER style. Both are applied together.
|
|
238
|
+
"""
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
# Effectively disable PyYAML's column-based line folding. Folding (not style)
|
|
242
|
+
# is what splits the template tags; a huge width keeps every scalar on one line
|
|
243
|
+
# even when literal-block style is unavailable.
|
|
244
|
+
_NO_FOLD_WIDTH = 1_000_000_000
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def _literal_str_representer(dumper: yaml.Dumper, data: str):
|
|
248
|
+
style = "|" if "\n" in data else None
|
|
249
|
+
return dumper.represent_scalar("tag:yaml.org,2002:str", data, style=style)
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
_BlockStyleDumper.add_representer(str, _literal_str_representer)
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def render_recipe_with_context(
|
|
256
|
+
recipe: dict | str | Path,
|
|
257
|
+
extra_env: dict[str, str],
|
|
258
|
+
*,
|
|
259
|
+
environment: Any = None,
|
|
260
|
+
) -> Optional[str]:
|
|
261
|
+
"""Resolve a recipe's context: block; write a rendered temp file.
|
|
262
|
+
|
|
263
|
+
Returns the path to the temp file (str) if a context: block was
|
|
264
|
+
present and rendered, or None if the recipe has no context: block
|
|
265
|
+
(caller can use the original recipe path unchanged).
|
|
266
|
+
|
|
267
|
+
`recipe` may be a parsed dict (e.g. the merged result of recipe_merge),
|
|
268
|
+
a path to a yaml file, or a Path object.
|
|
269
|
+
"""
|
|
270
|
+
if isinstance(recipe, (str, Path)):
|
|
271
|
+
path = Path(recipe)
|
|
272
|
+
with open(path, "r") as f:
|
|
273
|
+
doc = yaml.safe_load(f) or {}
|
|
274
|
+
else:
|
|
275
|
+
doc = recipe
|
|
276
|
+
|
|
277
|
+
env = {**os.environ, **(extra_env or {})}
|
|
278
|
+
context_block = doc.get("context")
|
|
279
|
+
|
|
280
|
+
if context_block:
|
|
281
|
+
rendered_chunks: list[str] = [_PREAMBLE]
|
|
282
|
+
for entry in context_block:
|
|
283
|
+
label = entry.get("label", "(unnamed)")
|
|
284
|
+
source = entry.get("source", "")
|
|
285
|
+
optional = bool(entry.get("optional", False))
|
|
286
|
+
body = _resolve_source(source, env, environment, optional=optional)
|
|
287
|
+
rendered_chunks.append(
|
|
288
|
+
_BLOCK_OPEN.format(label=label) + "\n"
|
|
289
|
+
+ _raw_wrap(body.rstrip()) + "\n"
|
|
290
|
+
+ _BLOCK_CLOSE
|
|
291
|
+
)
|
|
292
|
+
block_text = "\n\n".join(rendered_chunks)
|
|
293
|
+
# Env-substitute the recipe's OWN prompt prose, but never the pasted
|
|
294
|
+
# context: that text is data, and `${VAR}`/`$VAR` sequences in it must
|
|
295
|
+
# survive verbatim (a recap can legitimately mention $HOME or
|
|
296
|
+
# ${WINDOW_DAYS}). The context is also already raw-wrapped, so goose's
|
|
297
|
+
# own templater leaves it alone.
|
|
298
|
+
substituted_prompt = _substitute_env(doc.get("prompt", ""), env)
|
|
299
|
+
doc = {**doc, "prompt": block_text + "\n\n" + substituted_prompt}
|
|
300
|
+
doc.pop("context", None) # consumed; goose doesn't need to see it
|
|
301
|
+
else:
|
|
302
|
+
if not _prompt_needs_substitution(doc.get("prompt", "")):
|
|
303
|
+
# Nothing to render, nothing to substitute — caller can use the
|
|
304
|
+
# original recipe file unchanged.
|
|
305
|
+
return None
|
|
306
|
+
doc = substitute_env_in_prompt(doc, env)
|
|
307
|
+
|
|
308
|
+
tmp = tempfile.NamedTemporaryFile(
|
|
309
|
+
mode="w",
|
|
310
|
+
suffix=".rendered.yaml",
|
|
311
|
+
delete=False,
|
|
312
|
+
)
|
|
313
|
+
yaml.dump(doc, tmp, Dumper=_BlockStyleDumper, sort_keys=False,
|
|
314
|
+
default_flow_style=False, allow_unicode=True, width=_NO_FOLD_WIDTH)
|
|
315
|
+
tmp.close()
|
|
316
|
+
return tmp.name
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""Shape-specific Environment contracts.
|
|
2
|
+
|
|
3
|
+
Per ADR 0005 the framework Environment ABC has one abstract method
|
|
4
|
+
(env_vars). Shape-specific contracts live here as separate ABCs that
|
|
5
|
+
inherit from Environment and add their domain vocabulary:
|
|
6
|
+
|
|
7
|
+
CustomerPipelineEnvironment - for customer-acquisition pipelines.
|
|
8
|
+
ClaudeHandoffEnvironment - for Claude design-handoff engines.
|
|
9
|
+
|
|
10
|
+
A concrete environment subclasses whichever mixin matches its domain
|
|
11
|
+
(or bare Environment if no mixin fits). Recipes call env_method:<name>
|
|
12
|
+
against the live instance regardless of mixin lineage.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from .claude_handoff import ClaudeHandoffEnvironment
|
|
16
|
+
from .customer_pipeline import CustomerPipelineEnvironment
|
|
17
|
+
|
|
18
|
+
__all__ = ["ClaudeHandoffEnvironment", "CustomerPipelineEnvironment"]
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""ClaudeHandoffEnvironment — shape contract for Claude design-handoff engines.
|
|
2
|
+
|
|
3
|
+
Domain vocabulary: handoff folder, target repo, dev-up probe, panel
|
|
4
|
+
inventory, screenshot baseline. A handoff engine receives a
|
|
5
|
+
claude-handoff.toml in the target repo, drives Claude through a
|
|
6
|
+
survey → implement-panel → screenshot-verify → status loop, and never
|
|
7
|
+
provisions the target's dev environment itself.
|
|
8
|
+
|
|
9
|
+
Per the feedback memory `feedback-handoff-engine-provisioning-contract`:
|
|
10
|
+
the engine NEVER auto-provisions the target's dev stack. `dev_up_probe()`
|
|
11
|
+
checks dev is up; if not, the engine fails loud and the operator owns
|
|
12
|
+
the lifecycle.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from abc import abstractmethod
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
from ..environment import Environment
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class ClaudeHandoffEnvironment(Environment):
|
|
22
|
+
"""Environment contract for Claude design-handoff engines.
|
|
23
|
+
|
|
24
|
+
Required:
|
|
25
|
+
env_vars() - inherited from Environment.
|
|
26
|
+
handoff_dir() - directory of handoff specs the engine consumes.
|
|
27
|
+
target_repo() - root of the repo the handoff applies to.
|
|
28
|
+
dev_up_probe() - shell command (str) checking dev is reachable.
|
|
29
|
+
|
|
30
|
+
Optional:
|
|
31
|
+
panel_inventory(), screenshot_baseline_dir(), handoff_toml_path().
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
@abstractmethod
|
|
35
|
+
def handoff_dir(self) -> Path:
|
|
36
|
+
"""Directory containing handoff specs (markdown or toml) to consume."""
|
|
37
|
+
|
|
38
|
+
@abstractmethod
|
|
39
|
+
def target_repo(self) -> Path:
|
|
40
|
+
"""Root of the repository this handoff applies to."""
|
|
41
|
+
|
|
42
|
+
@abstractmethod
|
|
43
|
+
def dev_up_probe(self) -> str:
|
|
44
|
+
"""Shell command whose zero exit code means dev is reachable.
|
|
45
|
+
|
|
46
|
+
Example: 'curl -sf http://localhost:8000/healthz'. The engine
|
|
47
|
+
runs this in precheck; non-zero aborts with operator-action
|
|
48
|
+
instructing dev to be brought up.
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
def panel_inventory(self) -> str:
|
|
52
|
+
"""Plain-text inventory of panels / UI components in scope.
|
|
53
|
+
|
|
54
|
+
Default returns "" (no inventory enforced). Override to scope
|
|
55
|
+
the engine to a specific subset of the target.
|
|
56
|
+
"""
|
|
57
|
+
return ""
|
|
58
|
+
|
|
59
|
+
def screenshot_baseline_dir(self) -> Path | None:
|
|
60
|
+
"""Directory of pre-handoff baseline screenshots for verification.
|
|
61
|
+
|
|
62
|
+
Default None (no baseline; verifier runs without diffs). Override
|
|
63
|
+
to enable visual-regression checks.
|
|
64
|
+
"""
|
|
65
|
+
return None
|
|
66
|
+
|
|
67
|
+
def handoff_toml_path(self) -> Path | None:
|
|
68
|
+
"""Path to the target repo's claude-handoff.toml, if any.
|
|
69
|
+
|
|
70
|
+
Default None (the engine works from `handoff_dir()` alone).
|
|
71
|
+
Override when the target codebase carries its own per-target
|
|
72
|
+
config.
|
|
73
|
+
"""
|
|
74
|
+
return None
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""CustomerPipelineEnvironment — shape contract for customer-acquisition pipelines.
|
|
2
|
+
|
|
3
|
+
Domain vocabulary: prospects, lifecycles, outreach, research, broadcast,
|
|
4
|
+
discovery questions, founder journal. Concrete environments (Storm's
|
|
5
|
+
StormEnvironment, future BetaCo-style users) subclass this to inherit
|
|
6
|
+
the contract and implement each abstractmethod.
|
|
7
|
+
|
|
8
|
+
Recipes call these by name via env_method:<name> in their context:
|
|
9
|
+
blocks.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from abc import abstractmethod
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
from ..environment import Environment
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class CustomerPipelineEnvironment(Environment):
|
|
19
|
+
"""Environment contract for customer-acquisition engines.
|
|
20
|
+
|
|
21
|
+
Required:
|
|
22
|
+
env_vars() - inherited from Environment.
|
|
23
|
+
core_dir() - root of project's non-runtime data.
|
|
24
|
+
lifecycle_dirs() - canonical ordered list of (name, path) pairs.
|
|
25
|
+
lifecycle_dir(name) - one lifecycle dir by name.
|
|
26
|
+
output_dir(name) - one output dir by name (outreach, research, broadcast).
|
|
27
|
+
build_digest() - compact text summary of every routable prospect.
|
|
28
|
+
journal_text() - operator's working journal, or "" if absent.
|
|
29
|
+
manifest_text() - project-level static context (brand voice etc.).
|
|
30
|
+
|
|
31
|
+
Optional:
|
|
32
|
+
questions_dir(), insight_dir(), repo_activity(), questions_listing().
|
|
33
|
+
Default to empty / not-found sentinels; recipes that need them
|
|
34
|
+
should declare the source non-optional so a missing implementation
|
|
35
|
+
fails the run loud.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
# ---- paths -------------------------------------------------------
|
|
39
|
+
|
|
40
|
+
@abstractmethod
|
|
41
|
+
def core_dir(self) -> Path:
|
|
42
|
+
"""Root of the project's non-runtime data (foundation docs, journal, inputs)."""
|
|
43
|
+
|
|
44
|
+
@abstractmethod
|
|
45
|
+
def lifecycle_dirs(self) -> list[tuple[str, Path]]:
|
|
46
|
+
"""All lifecycle dirs in canonical order, including non-routable ones."""
|
|
47
|
+
|
|
48
|
+
@abstractmethod
|
|
49
|
+
def lifecycle_dir(self, name: str) -> Path:
|
|
50
|
+
"""One lifecycle dir by canonical name (e.g. 'potential', 'active')."""
|
|
51
|
+
|
|
52
|
+
@abstractmethod
|
|
53
|
+
def output_dir(self, name: str) -> Path:
|
|
54
|
+
"""One output dir by canonical name (outreach, research, broadcast)."""
|
|
55
|
+
|
|
56
|
+
# ---- content loaders --------------------------------------------
|
|
57
|
+
|
|
58
|
+
@abstractmethod
|
|
59
|
+
def build_digest(self) -> str:
|
|
60
|
+
"""Compact text summary of every routable prospect.
|
|
61
|
+
|
|
62
|
+
The pre-computed form that recipes paste in via env_method:.
|
|
63
|
+
Schema is environment-defined; engines treat the result as opaque text.
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
@abstractmethod
|
|
67
|
+
def journal_text(self) -> str:
|
|
68
|
+
"""Operator's working journal as text, or "" if the file is missing."""
|
|
69
|
+
|
|
70
|
+
@abstractmethod
|
|
71
|
+
def manifest_text(self) -> str:
|
|
72
|
+
"""Project-level static context (brand voice, framing, posture)."""
|
|
73
|
+
|
|
74
|
+
# ---- optional ---------------------------------------------------
|
|
75
|
+
|
|
76
|
+
def questions_dir(self) -> Path | None:
|
|
77
|
+
"""Living-doc workspace for discovery question files. None = unused."""
|
|
78
|
+
return None
|
|
79
|
+
|
|
80
|
+
def insight_dir(self) -> Path | None:
|
|
81
|
+
"""Workspace for cadence-triggered watcher outputs. None = unused."""
|
|
82
|
+
return None
|
|
83
|
+
|
|
84
|
+
def repo_activity(self) -> str:
|
|
85
|
+
"""Recent commit activity across the project's tracked repos.
|
|
86
|
+
|
|
87
|
+
Default returns "" (no repo activity tracked). Override to wire
|
|
88
|
+
the activity-watch recipe to your repos.
|
|
89
|
+
"""
|
|
90
|
+
return ""
|
|
91
|
+
|
|
92
|
+
def questions_listing(self) -> str:
|
|
93
|
+
"""Plain-text listing of the discovery questions workspace.
|
|
94
|
+
|
|
95
|
+
Default returns "" (workspace absent). Override to enumerate
|
|
96
|
+
files for the questions-due recipe.
|
|
97
|
+
"""
|
|
98
|
+
return ""
|
gooseloop/engine.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""Engine abstract base class.
|
|
2
|
+
|
|
3
|
+
Per ADRs 0001 (engine returns Pipeline), 0006 (named-slot Pipeline), 0007
|
|
4
|
+
(BranchPolicy registry). The framework runs the Pipeline; the engine owns
|
|
5
|
+
what's inside it.
|
|
6
|
+
|
|
7
|
+
There is no decorator-based engine registry in v1.0. The CLI imports the
|
|
8
|
+
engine module declared in gooseloop.toml and reads the engine class from
|
|
9
|
+
that module's `engine` attribute (set by the engine's __init__.py). This
|
|
10
|
+
keeps gooseloop.toml as the single source of truth for "which engine"
|
|
11
|
+
without runtime registration side effects.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from abc import ABC, abstractmethod
|
|
15
|
+
|
|
16
|
+
from .branch_policy import BranchPolicy
|
|
17
|
+
from .phase import Context, Pipeline
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class Engine(ABC):
|
|
21
|
+
"""Implement this to plug into GooseLooper.
|
|
22
|
+
|
|
23
|
+
Required:
|
|
24
|
+
name — short slug for logs, footers, config.
|
|
25
|
+
pipeline() — returns the Pipeline (review + body + summary).
|
|
26
|
+
|
|
27
|
+
Optional:
|
|
28
|
+
branch_policies — dict of recipe-name → BranchPolicy for routing[].
|
|
29
|
+
May be overridden at the class level (static policies) or as
|
|
30
|
+
an instance attribute / @property (policies that close over
|
|
31
|
+
engine state like an output directory).
|
|
32
|
+
base_env() — env vars injected into every recipe call.
|
|
33
|
+
precheck() — run before the pipeline; raise to abort.
|
|
34
|
+
recipes_dir() — where engine-bundled recipes live.
|
|
35
|
+
default_model() — engine-recommended model.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
branch_policies: dict[str, BranchPolicy] = {}
|
|
39
|
+
|
|
40
|
+
@property
|
|
41
|
+
@abstractmethod
|
|
42
|
+
def name(self) -> str:
|
|
43
|
+
"""Short slug. Conventionally lowercase, hyphenated."""
|
|
44
|
+
|
|
45
|
+
@abstractmethod
|
|
46
|
+
def pipeline(self, ctx: Context) -> Pipeline:
|
|
47
|
+
"""The Pipeline for one begin_loop() pass.
|
|
48
|
+
|
|
49
|
+
Free to do pre-pipeline work (snapshot state, build a digest) and
|
|
50
|
+
bake the results into the first Phase's build_env or ctx.artifacts.
|
|
51
|
+
Must return a Pipeline with review and summary phases set; body
|
|
52
|
+
may be empty.
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
def base_env(self) -> dict[str, str]:
|
|
56
|
+
"""Engine-only env additions. Environment.env_vars() covers paths."""
|
|
57
|
+
return {}
|
|
58
|
+
|
|
59
|
+
def precheck(self, ctx: Context) -> None:
|
|
60
|
+
"""Run once before the pipeline. Raise to abort the pass."""
|
|
61
|
+
|
|
62
|
+
def recipes_dir(self) -> str:
|
|
63
|
+
"""Engine-bundled recipes location, relative to the engine module."""
|
|
64
|
+
return "recipes"
|
|
65
|
+
|
|
66
|
+
def default_model(self) -> str | None:
|
|
67
|
+
"""Engine-suggested default model. None = use whatever the Looper has."""
|
|
68
|
+
return None
|
gooseloop/environment.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Environment abstract base class.
|
|
2
|
+
|
|
3
|
+
Per ADR 0005 the framework ABC has exactly one abstract method: env_vars().
|
|
4
|
+
Shape-specific contracts (Storm's customer pipeline, Claude design-handoff,
|
|
5
|
+
future domains) live as separate ABCs under gooseloop.contrib.* and inherit
|
|
6
|
+
from this base.
|
|
7
|
+
|
|
8
|
+
Engines pull paths and project-data via ctx.environment, calling whatever
|
|
9
|
+
methods the concrete instance exposes. Recipes paste content via the
|
|
10
|
+
env_method:<name> source kind in their context: block.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from abc import ABC, abstractmethod
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class Environment(ABC):
|
|
17
|
+
"""Minimum the framework requires from an Environment.
|
|
18
|
+
|
|
19
|
+
A concrete environment must return the env vars the looper should merge
|
|
20
|
+
into every recipe call. Everything else (paths, loaders, project data)
|
|
21
|
+
is shape-specific and lives on a contrib mixin or the concrete class.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
@abstractmethod
|
|
25
|
+
def env_vars(self) -> dict[str, str]:
|
|
26
|
+
"""Env vars merged into every recipe call.
|
|
27
|
+
|
|
28
|
+
Conventionally includes ${VAR} interpolations the engine's recipes
|
|
29
|
+
reference (e.g. POTENTIAL_DIR, OUTREACH_DIR, CORE_DIR for a customer
|
|
30
|
+
pipeline; HANDOFF_DIR, TARGET_REPO for a handoff engine).
|
|
31
|
+
"""
|