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 ADDED
@@ -0,0 +1,42 @@
1
+ """gooseloop — an execution shell for goose-recipe pipelines.
2
+
3
+ Public surface:
4
+
5
+ GooseLooper - the execution shell
6
+ Engine - abstract base for engines
7
+ Environment - abstract base for environments (just env_vars)
8
+ Phase - one recipe invocation in a Pipeline
9
+ Pipeline - named-slot dataclass: review + body + summary
10
+ Context - passed to phase callables; typed ledger methods
11
+ BranchPolicy - per-recipe rules for routing[] -> Phase building
12
+ LooperConfig - resolved gooseloop.toml as a value object
13
+ predicates - success_predicate factories
14
+ protocol - ReviewOutput / OperatorAction / RoutingEntry types
15
+ toolkit - stdlib-only engine helpers (Source, fetch_url, state io)
16
+ artifact - versioned artifact contracts for engine composition
17
+ """
18
+
19
+ from . import artifact, predicates, protocol, toolkit
20
+ from .branch_policy import BranchPolicy
21
+ from .config import LooperConfig
22
+ from .engine import Engine
23
+ from .environment import Environment
24
+ from .looper import GooseLooper
25
+ from .phase import Context, Phase, Pipeline
26
+
27
+ __all__ = [
28
+ "BranchPolicy",
29
+ "Context",
30
+ "Engine",
31
+ "Environment",
32
+ "GooseLooper",
33
+ "LooperConfig",
34
+ "Phase",
35
+ "Pipeline",
36
+ "artifact",
37
+ "predicates",
38
+ "protocol",
39
+ "toolkit",
40
+ ]
41
+
42
+ __version__ = "0.1.0"
gooseloop/__main__.py ADDED
@@ -0,0 +1,181 @@
1
+ """`gooseloop` CLI entry point.
2
+
3
+ Subcommands:
4
+ run Run the configured engine, one pass.
5
+ --review-only Stop after the review phase.
6
+ --review-overlay PATH Stack a review-recipe overlay (repeatable).
7
+ --summary-overlay PATH Stack a summary-recipe overlay (repeatable).
8
+ --no-save Don't write a session folder.
9
+ --no-validate Skip engine.precheck().
10
+ --model NAME Override the configured model.
11
+
12
+ recipe --resolve NAME Print the fully-merged recipe NAME.
13
+
14
+ engines Print the configured engine module.
15
+
16
+ Engine discovery: gooseloop.toml's [gooseloop] engine_module = "..." is
17
+ imported and its module-level `engine` attribute is instantiated. Engines
18
+ expose their class as a module-level callable in their __init__.py.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import argparse
24
+ import importlib
25
+ import sys
26
+ from pathlib import Path
27
+
28
+ from .config import LooperConfig
29
+ from .engine import Engine
30
+ from .environment import Environment
31
+ from .looper import GooseLooper
32
+ from .recipe_merge import load_layered_recipe, resolved_recipe_yaml
33
+
34
+
35
+ def main(argv: list[str] | None = None) -> int:
36
+ parser = argparse.ArgumentParser(prog="gooseloop")
37
+ sub = parser.add_subparsers(dest="cmd", required=True)
38
+
39
+ run = sub.add_parser("run", help="run the configured engine, one pass")
40
+ run.add_argument("-e", "--engine", default=None, metavar="MODULE",
41
+ help="override the engine module (default: from gooseloop.toml)")
42
+ run.add_argument("--review-only", action="store_true",
43
+ help="stop after the review phase")
44
+ run.add_argument("--review-overlay", action="append", default=[],
45
+ metavar="PATH", help="stack a review-recipe overlay")
46
+ run.add_argument("--summary-overlay", action="append", default=[],
47
+ metavar="PATH", help="stack a summary-recipe overlay")
48
+ run.add_argument("--no-save", action="store_true",
49
+ help="do not write a session folder")
50
+ run.add_argument("--no-validate", action="store_true",
51
+ help="skip engine.precheck()")
52
+ run.add_argument("--model", default=None, help="override the configured model")
53
+
54
+ rec = sub.add_parser("recipe", help="recipe utilities")
55
+ rec.add_argument("--resolve", metavar="NAME",
56
+ help="print the fully-merged recipe NAME")
57
+ rec.add_argument("--overlay", action="append", default=[], metavar="PATH",
58
+ help="extra overlay path to include in the merge")
59
+
60
+ sub.add_parser("engines", help="show the configured engine module")
61
+
62
+ args = parser.parse_args(argv)
63
+
64
+ if args.cmd == "run":
65
+ return _cmd_run(args)
66
+ if args.cmd == "recipe":
67
+ return _cmd_recipe(args)
68
+ if args.cmd == "engines":
69
+ return _cmd_engines(args)
70
+ parser.error(f"unknown command: {args.cmd}")
71
+ return 2 # unreachable
72
+
73
+
74
+ def _cmd_run(args: argparse.Namespace) -> int:
75
+ config = LooperConfig.load()
76
+ engine, environment = _load_engine_and_environment(config, engine_override=args.engine)
77
+ looper = GooseLooper(
78
+ engine=engine,
79
+ environment=environment,
80
+ config=config,
81
+ model=args.model,
82
+ save=not args.no_save,
83
+ validate=not args.no_validate,
84
+ review_only=args.review_only,
85
+ review_overlays=[Path(p) for p in args.review_overlay],
86
+ summary_overlays=[Path(p) for p in args.summary_overlay],
87
+ )
88
+ result = looper.begin_loop()
89
+ return 0 if result.get("review_status") != "error" else 1
90
+
91
+
92
+ def _cmd_recipe(args: argparse.Namespace) -> int:
93
+ if not args.resolve:
94
+ print("usage: gooseloop recipe --resolve NAME [--overlay PATH ...]",
95
+ file=sys.stderr)
96
+ return 2
97
+ config = LooperConfig.load()
98
+ base_path = (config.anchor / args.resolve).resolve()
99
+ if not base_path.exists():
100
+ # Try with .yaml suffix if user passed a bare name.
101
+ candidate = base_path.with_suffix(".yaml")
102
+ if candidate.exists():
103
+ base_path = candidate
104
+ else:
105
+ print(f"recipe not found: {base_path}", file=sys.stderr)
106
+ return 1
107
+ local = base_path.with_suffix("").with_name(base_path.stem + ".local").with_suffix(".yaml")
108
+ overlay_paths = [Path(p) for p in args.overlay]
109
+ merged = load_layered_recipe(
110
+ base_path,
111
+ local_path=local if local.exists() else None,
112
+ overlay_paths=overlay_paths,
113
+ )
114
+ print(resolved_recipe_yaml(merged))
115
+ return 0
116
+
117
+
118
+ def _cmd_engines(args: argparse.Namespace) -> int:
119
+ config = LooperConfig.load()
120
+ print(f"engine_module: {config.engine_module}")
121
+ try:
122
+ mod = importlib.import_module(config.engine_module)
123
+ engine_cls = getattr(mod, "engine", None)
124
+ if engine_cls is None:
125
+ print(" (module has no `engine` attribute)", file=sys.stderr)
126
+ return 1
127
+ print(f"engine class: {engine_cls.__module__}.{engine_cls.__name__}")
128
+ except ImportError as e:
129
+ print(f" import failed: {e}", file=sys.stderr)
130
+ return 1
131
+ return 0
132
+
133
+
134
+ def _load_engine_and_environment(
135
+ config: LooperConfig,
136
+ *,
137
+ engine_override: str | None = None,
138
+ ) -> tuple[Engine, Environment | None]:
139
+ """Import the engine module; instantiate its `engine` attribute.
140
+
141
+ Convention: an engine package exposes a module-level `engine`
142
+ callable (often the class itself) and optionally an `environment`
143
+ callable. Both are instantiated with no arguments by default.
144
+ Engines requiring constructor arguments should ship factory
145
+ callables in those slots.
146
+
147
+ `engine_override` (from `-e`/`--engine`) supersedes
148
+ `config.engine_module` when provided.
149
+ """
150
+ module_name = engine_override or config.engine_module
151
+ sys.path.insert(0, str(config.anchor))
152
+ try:
153
+ mod = importlib.import_module(module_name)
154
+ except ImportError as e:
155
+ raise SystemExit(
156
+ f"gooseloop: cannot import engine module {module_name!r}: {e}"
157
+ )
158
+ engine_obj = getattr(mod, "engine", None)
159
+ if engine_obj is None:
160
+ raise SystemExit(
161
+ f"gooseloop: engine module {module_name!r} has no `engine` attribute"
162
+ )
163
+ engine = engine_obj() if callable(engine_obj) and not isinstance(engine_obj, Engine) else engine_obj
164
+ if not isinstance(engine, Engine):
165
+ raise SystemExit(
166
+ f"gooseloop: {module_name}.engine did not yield an Engine instance"
167
+ )
168
+
169
+ env_obj = getattr(mod, "environment", None)
170
+ environment: Environment | None = None
171
+ if env_obj is not None:
172
+ environment = env_obj() if callable(env_obj) and not isinstance(env_obj, Environment) else env_obj
173
+ if not isinstance(environment, Environment):
174
+ raise SystemExit(
175
+ f"gooseloop: {module_name}.environment did not yield an Environment instance"
176
+ )
177
+ return engine, environment
178
+
179
+
180
+ if __name__ == "__main__":
181
+ sys.exit(main())
gooseloop/artifact.py ADDED
@@ -0,0 +1,82 @@
1
+ """Versioned artifact contracts: how engines compose without knowing each other.
2
+
3
+ Per PROTOCOL.md §12. When one engine's output is another engine's input, the
4
+ artifact file is the interface between them, and an interface needs a version.
5
+ The producing engine stamps a `schema_version` key into the artifact; the
6
+ consuming engine checks it at read time with check_artifact_version().
7
+
8
+ The semantics deliberately mirror the review protocol_version check in
9
+ protocol.py, because it is the same problem one level up:
10
+
11
+ - Same major, any minor: compatible. Additive schema changes (a new
12
+ optional field, a new enum value the reader ignores) bump the minor.
13
+ - Different major, or an unparseable version: refused loudly with
14
+ ArtifactVersionError. A major mismatch means the reader may
15
+ misinterpret every entry; limping along is the silent time bomb.
16
+ - Missing version: the artifact is still read, and a problem string is
17
+ returned nudging the operator to stamp the file. Fail-safe runs in the
18
+ KEEP direction: data we cannot positively classify is kept and named
19
+ out loud, never refused on a technicality. (Pre-versioning artifacts
20
+ sealed by hand exist; they should not stop working the day the
21
+ contract gains a version key.)
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ from typing import Any
27
+
28
+
29
+ class ArtifactVersionError(Exception):
30
+ """The artifact declares a schema version this reader cannot honour."""
31
+
32
+
33
+ def check_artifact_version(
34
+ data: dict[str, Any],
35
+ supported: str,
36
+ *,
37
+ key: str = "schema_version",
38
+ what: str = "artifact",
39
+ ) -> list[str]:
40
+ """Check a parsed artifact's declared schema version against `supported`.
41
+
42
+ Returns a list of problem strings (empty when the version is present and
43
+ compatible). Raises ArtifactVersionError on a major mismatch or an
44
+ unparseable declared version.
45
+
46
+ Parameters:
47
+ data: the parsed artifact (the top-level mapping).
48
+ supported: the version this reader ships, e.g. "1.0".
49
+ key: the version key inside the artifact.
50
+ what: human name for the artifact, used in messages.
51
+ """
52
+ supported_major = _major(supported, what=f"supported version for {what}")
53
+
54
+ declared = data.get(key)
55
+ if declared is None or str(declared).strip() == "":
56
+ return [
57
+ f"{what} has no {key}; assuming {supported} - "
58
+ f'add `{key} = "{supported}"` to the file'
59
+ ]
60
+
61
+ declared = str(declared).strip()
62
+ try:
63
+ declared_major = _major(declared, what=what)
64
+ except ArtifactVersionError:
65
+ raise ArtifactVersionError(
66
+ f"{what} declares {key} {declared!r}, which is not parseable; "
67
+ f"this reader supports major {supported_major}"
68
+ ) from None
69
+
70
+ if declared_major != supported_major:
71
+ raise ArtifactVersionError(
72
+ f"{what} declares {key} {declared!r} (major {declared_major}); "
73
+ f"this reader supports major {supported_major} only"
74
+ )
75
+ return []
76
+
77
+
78
+ def _major(version: str, *, what: str) -> int:
79
+ try:
80
+ return int(str(version).split(".", 1)[0])
81
+ except (ValueError, IndexError):
82
+ raise ArtifactVersionError(f"{what}: version {version!r} is not parseable")
@@ -0,0 +1,49 @@
1
+ """BranchPolicy — per-recipe rules the framework applies when building body Phases.
2
+
3
+ Per ADR 0007 and PROTOCOL.md §5. The framework consults the engine's
4
+ branch_policies dict for each routing[] entry the review emitted, looking
5
+ up by recipe name. Recipes with no entry get BranchPolicy() defaults
6
+ (no skip, no path tracking, no extra predicate, intent unchecked).
7
+
8
+ Authors of an engine register policies like:
9
+
10
+ class MyEngine(Engine):
11
+ branch_policies = {
12
+ "to-outreach": BranchPolicy(
13
+ skip_when=lambda p: (outreach_dir / f"{p['slug']}.md").exists(),
14
+ output_path=lambda p: outreach_dir / f"{p['slug']}.md",
15
+ intent="produce",
16
+ ),
17
+ }
18
+ """
19
+
20
+ from dataclasses import dataclass
21
+ from pathlib import Path
22
+ from typing import Any, Callable, Literal, Optional
23
+
24
+
25
+ Intent = Literal["produce", "edit", "edit-or-produce"]
26
+
27
+
28
+ @dataclass
29
+ class BranchPolicy:
30
+ """Per-recipe rules. All fields optional; the default is the no-op policy.
31
+
32
+ skip_when: called with the routing entry's params dict.
33
+ Truthy return skips the phase. A str return is used as the
34
+ skip reason in the session log.
35
+ output_path: called with the params dict to compute the deterministic
36
+ file path the recipe is expected to write. The framework uses this
37
+ to derive a default success predicate (file_nonempty) and to log
38
+ the path on success.
39
+ predicate: explicit success predicate override. Takes the recipe's
40
+ stdout. If unset and output_path is set, the framework derives a
41
+ file_nonempty predicate from output_path.
42
+ intent: declarative tag for the recipe's intent against output_path.
43
+ Currently informational; reserved for future intent-reconciliation
44
+ checks. None = unchecked.
45
+ """
46
+ skip_when: Optional[Callable[[dict[str, Any]], "bool | str | None"]] = None
47
+ output_path: Optional[Callable[[dict[str, Any]], Optional[Path]]] = None
48
+ predicate: Optional[Callable[[str], bool]] = None
49
+ intent: Optional[Intent] = None
gooseloop/config.py ADDED
@@ -0,0 +1,104 @@
1
+ """LooperConfig — value object for gooseloop.toml.
2
+
3
+ Loaded once at the top of a run via LooperConfig.load() and passed
4
+ explicitly. No module-level singleton, no clear-cache shims, no global
5
+ state. Tests construct LooperConfig directly with overrides.
6
+ """
7
+
8
+ import sys
9
+ import tomllib
10
+ from dataclasses import dataclass, field
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+
15
+ CONFIG_FILENAME = "gooseloop.toml"
16
+
17
+
18
+ DEFAULTS: dict[str, Any] = {
19
+ "gooseloop": {
20
+ "default_model": "openrouter/owl-alpha",
21
+ "sessions_dir": "reviews/sessions",
22
+ "engine_module": "engines.hello_world",
23
+ "environment_config": "",
24
+ "max_queue_depth": 50,
25
+ "review_recipe": "review.yaml",
26
+ "summary_recipe": "summary.yaml",
27
+ "retry": {
28
+ "max_retries": 6,
29
+ "base_delay": 5,
30
+ },
31
+ },
32
+ }
33
+
34
+
35
+ @dataclass
36
+ class RetrySettings:
37
+ max_retries: int = 6
38
+ base_delay: int = 5
39
+
40
+
41
+ @dataclass
42
+ class LooperConfig:
43
+ """Resolved gooseloop.toml. Construct via LooperConfig.load(); never global."""
44
+ default_model: str = "openrouter/owl-alpha"
45
+ sessions_dir: Path = field(default_factory=lambda: Path("reviews/sessions"))
46
+ engine_module: str = "engines.hello_world"
47
+ environment_config: Path | None = None
48
+ max_queue_depth: int = 50
49
+ review_recipe: str = "review.yaml"
50
+ summary_recipe: str = "summary.yaml"
51
+ retry: RetrySettings = field(default_factory=RetrySettings)
52
+ anchor: Path = field(default_factory=Path.cwd)
53
+
54
+ @classmethod
55
+ def load(cls, anchor: Path | None = None, *, warn_on_missing: bool = True) -> "LooperConfig":
56
+ """Load gooseloop.toml from `anchor` (default: cwd). Missing file = defaults."""
57
+ anchor = (anchor or Path.cwd()).resolve()
58
+ path = anchor / CONFIG_FILENAME
59
+ if path.exists():
60
+ with open(path, "rb") as f:
61
+ raw = tomllib.load(f)
62
+ merged = _deep_merge(DEFAULTS, raw)
63
+ else:
64
+ if warn_on_missing:
65
+ print(
66
+ f"[gooseloop] {CONFIG_FILENAME} not found in {anchor}; using defaults.",
67
+ file=sys.stderr,
68
+ )
69
+ merged = DEFAULTS
70
+ return cls._from_merged(merged, anchor)
71
+
72
+ @classmethod
73
+ def _from_merged(cls, merged: dict[str, Any], anchor: Path) -> "LooperConfig":
74
+ section = merged["gooseloop"]
75
+ env_cfg = section.get("environment_config", "") or ""
76
+ return cls(
77
+ default_model=section["default_model"],
78
+ sessions_dir=_resolve(section["sessions_dir"], anchor),
79
+ engine_module=section["engine_module"],
80
+ environment_config=_resolve(env_cfg, anchor) if env_cfg else None,
81
+ max_queue_depth=int(section["max_queue_depth"]),
82
+ review_recipe=section.get("review_recipe", "review.yaml"),
83
+ summary_recipe=section.get("summary_recipe", "summary.yaml"),
84
+ retry=RetrySettings(
85
+ max_retries=int(section["retry"]["max_retries"]),
86
+ base_delay=int(section["retry"]["base_delay"]),
87
+ ),
88
+ anchor=anchor,
89
+ )
90
+
91
+
92
+ def _deep_merge(base: dict, override: dict) -> dict:
93
+ out = dict(base)
94
+ for k, v in override.items():
95
+ if isinstance(v, dict) and isinstance(out.get(k), dict):
96
+ out[k] = _deep_merge(out[k], v)
97
+ else:
98
+ out[k] = v
99
+ return out
100
+
101
+
102
+ def _resolve(path_str: str, anchor: Path) -> Path:
103
+ p = Path(path_str)
104
+ return p if p.is_absolute() else (anchor / p).resolve()