gooseloop 0.1.0__tar.gz

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 (47) hide show
  1. gooseloop-0.1.0/LICENSE +21 -0
  2. gooseloop-0.1.0/PKG-INFO +169 -0
  3. gooseloop-0.1.0/README.md +126 -0
  4. gooseloop-0.1.0/gooseloop/__init__.py +42 -0
  5. gooseloop-0.1.0/gooseloop/__main__.py +181 -0
  6. gooseloop-0.1.0/gooseloop/artifact.py +82 -0
  7. gooseloop-0.1.0/gooseloop/branch_policy.py +49 -0
  8. gooseloop-0.1.0/gooseloop/config.py +104 -0
  9. gooseloop-0.1.0/gooseloop/context_prepend.py +316 -0
  10. gooseloop-0.1.0/gooseloop/contrib/__init__.py +18 -0
  11. gooseloop-0.1.0/gooseloop/contrib/claude_handoff.py +74 -0
  12. gooseloop-0.1.0/gooseloop/contrib/customer_pipeline.py +98 -0
  13. gooseloop-0.1.0/gooseloop/engine.py +68 -0
  14. gooseloop-0.1.0/gooseloop/environment.py +31 -0
  15. gooseloop-0.1.0/gooseloop/extract.py +200 -0
  16. gooseloop-0.1.0/gooseloop/footer.py +75 -0
  17. gooseloop-0.1.0/gooseloop/goose.py +340 -0
  18. gooseloop-0.1.0/gooseloop/looper.py +581 -0
  19. gooseloop-0.1.0/gooseloop/phase.py +146 -0
  20. gooseloop-0.1.0/gooseloop/predicates.py +69 -0
  21. gooseloop-0.1.0/gooseloop/protocol.py +197 -0
  22. gooseloop-0.1.0/gooseloop/recipe_merge.py +152 -0
  23. gooseloop-0.1.0/gooseloop/session.py +41 -0
  24. gooseloop-0.1.0/gooseloop/text.py +60 -0
  25. gooseloop-0.1.0/gooseloop/toolkit.py +243 -0
  26. gooseloop-0.1.0/gooseloop.egg-info/PKG-INFO +169 -0
  27. gooseloop-0.1.0/gooseloop.egg-info/SOURCES.txt +45 -0
  28. gooseloop-0.1.0/gooseloop.egg-info/dependency_links.txt +1 -0
  29. gooseloop-0.1.0/gooseloop.egg-info/entry_points.txt +2 -0
  30. gooseloop-0.1.0/gooseloop.egg-info/requires.txt +5 -0
  31. gooseloop-0.1.0/gooseloop.egg-info/top_level.txt +1 -0
  32. gooseloop-0.1.0/pyproject.toml +39 -0
  33. gooseloop-0.1.0/setup.cfg +4 -0
  34. gooseloop-0.1.0/tests/test_artifact.py +62 -0
  35. gooseloop-0.1.0/tests/test_branch_policy_and_looper.py +393 -0
  36. gooseloop-0.1.0/tests/test_config.py +58 -0
  37. gooseloop-0.1.0/tests/test_context_prepend.py +391 -0
  38. gooseloop-0.1.0/tests/test_doc_drift.py +588 -0
  39. gooseloop-0.1.0/tests/test_engine_override.py +40 -0
  40. gooseloop-0.1.0/tests/test_extract.py +235 -0
  41. gooseloop-0.1.0/tests/test_git_recap.py +390 -0
  42. gooseloop-0.1.0/tests/test_goose_failure_modes.py +161 -0
  43. gooseloop-0.1.0/tests/test_phase_and_pipeline.py +104 -0
  44. gooseloop-0.1.0/tests/test_predicates.py +50 -0
  45. gooseloop-0.1.0/tests/test_protocol.py +192 -0
  46. gooseloop-0.1.0/tests/test_recipe_merge.py +158 -0
  47. gooseloop-0.1.0/tests/test_toolkit.py +194 -0
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Storm Developments
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,169 @@
1
+ Metadata-Version: 2.4
2
+ Name: gooseloop
3
+ Version: 0.1.0
4
+ Summary: Execution shell for goose-recipe pipelines
5
+ Author: Storm Developments
6
+ License: MIT License
7
+
8
+ Copyright (c) 2026 Storm Developments
9
+
10
+ Permission is hereby granted, free of charge, to any person obtaining a copy
11
+ of this software and associated documentation files (the "Software"), to deal
12
+ in the Software without restriction, including without limitation the rights
13
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
14
+ copies of the Software, and to permit persons to whom the Software is
15
+ furnished to do so, subject to the following conditions:
16
+
17
+ The above copyright notice and this permission notice shall be included in all
18
+ copies or substantial portions of the Software.
19
+
20
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
21
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
22
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
23
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
24
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
25
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
26
+ SOFTWARE.
27
+
28
+ Project-URL: Source, https://gitforge.ca/storm/gooseloop
29
+ Keywords: goose,llm,pipeline,agent,automation
30
+ Classifier: Development Status :: 3 - Alpha
31
+ Classifier: License :: OSI Approved :: MIT License
32
+ Classifier: Programming Language :: Python :: 3
33
+ Classifier: Programming Language :: Python :: 3.11
34
+ Classifier: Programming Language :: Python :: 3.12
35
+ Requires-Python: >=3.11
36
+ Description-Content-Type: text/markdown
37
+ License-File: LICENSE
38
+ Requires-Dist: PyYAML>=6.0
39
+ Provides-Extra: dev
40
+ Requires-Dist: pytest>=7; extra == "dev"
41
+ Requires-Dist: minijinja>=2; extra == "dev"
42
+ Dynamic: license-file
43
+
44
+ # gooseloop
45
+
46
+ An execution shell for [goose](https://block.github.io/goose/) recipe pipelines.
47
+
48
+ `gooseloop` runs an **Engine** against an **Environment**, driving a `Pipeline`
49
+ of `review -> body -> summary` through goose, with retry, rate-limit handling,
50
+ session bookkeeping, recipe overlay merging, and a typed session ledger.
51
+
52
+ It is generic. The framework knows nothing about your domain. Engines plug in.
53
+
54
+ ## Install
55
+
56
+ ```
57
+ pip install gooseloop
58
+ ```
59
+
60
+ Or, from a checkout:
61
+
62
+ ```
63
+ pip install -e .
64
+ ```
65
+
66
+ Requires Python 3.11+, the `goose` CLI on `$PATH`, and a YAML recipe directory.
67
+
68
+ ## The shape
69
+
70
+ Three primitives:
71
+
72
+ - **Engine** (verbs). Returns a `Pipeline(review, body, summary)`. Owns
73
+ branching, recipes, scoring, retry rules.
74
+ - **Environment** (nouns). One abstract method (`env_vars()`). Subclasses or
75
+ contrib mixins (`gooseloop.contrib.CustomerPipelineEnvironment`,
76
+ `gooseloop.contrib.ClaudeHandoffEnvironment`) add shape-specific contracts.
77
+ - **Pipeline** (the bookend). Review runs first and emits structured routing.
78
+ Body phases run in queue order (cadence phases + review-spawned branches).
79
+ Summary runs last and reads the final ledger.
80
+
81
+ The contract is documented in [`PROTOCOL.md`](PROTOCOL.md).
82
+ The design history lives in [`docs/adr/`](docs/adr/).
83
+
84
+ ## Five minute tour
85
+
86
+ ```python
87
+ from gooseloop import GooseLooper, Engine, Environment, Phase, Pipeline
88
+
89
+ class HelloEnvironment(Environment):
90
+ def env_vars(self) -> dict[str, str]:
91
+ return {"GREETING": "hello"}
92
+
93
+ class HelloEngine(Engine):
94
+ name = "hello-world"
95
+
96
+ def pipeline(self, ctx) -> Pipeline:
97
+ return Pipeline(
98
+ review=Phase(name="review", recipe_path="recipes/review.yaml"),
99
+ body=[Phase(name="greet", recipe_path="recipes/greet.yaml")],
100
+ summary=Phase(name="summary", recipe_path="recipes/summary.yaml"),
101
+ )
102
+
103
+ GooseLooper(engine=HelloEngine(), environment=HelloEnvironment()).begin_loop()
104
+ ```
105
+
106
+ The framework ships a working version of this engine under `engines/hello_world/`.
107
+
108
+ ## Built-in engines
109
+
110
+ Three reference engines ship in `engines/`. They live alongside the framework,
111
+ not inside the installed wheel, so run them **from a checkout** (the repo root,
112
+ where `engines/` is importable) and select one with `-e <module>`:
113
+
114
+ | Engine | What it does | Run (from the repo root) |
115
+ |--------|--------------|--------------------------|
116
+ | `hello_world` | The minimal reference engine (the tour above). | `python3 -m gooseloop run -e engines.hello_world` |
117
+ | `git_recap` | Summarises your recent commits across configured repos into a changelog. Configure `[git_recap]` in `gooseloop.toml`. | `python3 -m gooseloop run -e engines.git_recap` |
118
+ | `doc_drift` | Finds derived docs/pages that fell behind their canonical source and drafts a patch to seal. Configure `[doc_drift]`, then `cp doc-map.example.toml doc-map.toml` and edit. | `python3 -m gooseloop run -e engines.doc_drift` |
119
+
120
+ Drop `-e` to run whatever `[gooseloop] engine_module` points at (`hello_world`
121
+ by default). The installed `gooseloop` console script is equivalent to
122
+ `python3 -m gooseloop`, but the built-in engines above still need a checkout on
123
+ the path. Add `--review-only` to any of them to stop after the review phase.
124
+
125
+ ## Recipe overlay merge
126
+
127
+ Recipes compose docker-compose style. The looper resolves layers in this order:
128
+
129
+ 1. Base recipe (declared in `gooseloop.toml`).
130
+ 2. `<name>.local.yaml` (gitignored; per-machine).
131
+ 3. `--review-overlay X.yaml` / `--summary-overlay X.yaml` (ad-hoc).
132
+
133
+ Inspect the merged result with `gooseloop recipe --resolve <name>`.
134
+
135
+ Merge rules table is in [PROTOCOL.md §6](PROTOCOL.md).
136
+
137
+ ## Contrib mixins
138
+
139
+ Domain-shaped Environment ABCs ship under `gooseloop.contrib`:
140
+
141
+ - `CustomerPipelineEnvironment`: customer-acquisition pipelines
142
+ (`build_digest()`, `journal_text()`, `lifecycle_dirs()`, ...).
143
+ - `ClaudeHandoffEnvironment`: Claude design-handoff engines
144
+ (`handoff_dir()`, `target_repo()`, `dev_up_probe()`, ...).
145
+
146
+ An engine with no fit subclasses bare `Environment` and writes one method.
147
+
148
+ ## CLI
149
+
150
+ ```
151
+ gooseloop run # run the configured engine, one pass
152
+ gooseloop run --review-only # stop after review
153
+ gooseloop run --review-overlay x.yaml --summary-overlay y.yaml
154
+ gooseloop recipe --resolve review # print fully-merged recipe
155
+ gooseloop engines # list known engines from gooseloop.toml
156
+ ```
157
+
158
+ `gooseloop.toml` at the project root configures which engine, which recipes,
159
+ retry tuning, sessions dir.
160
+
161
+ ## License
162
+
163
+ MIT. See [`LICENSE`](LICENSE).
164
+
165
+ ## Provenance
166
+
167
+ `gooseloop` was extracted from Storm Developments' customer-acquisition pipeline
168
+ in June 2026. Storm's customer engine remains the reference consumer; the
169
+ framework is independent and OSS-first.
@@ -0,0 +1,126 @@
1
+ # gooseloop
2
+
3
+ An execution shell for [goose](https://block.github.io/goose/) recipe pipelines.
4
+
5
+ `gooseloop` runs an **Engine** against an **Environment**, driving a `Pipeline`
6
+ of `review -> body -> summary` through goose, with retry, rate-limit handling,
7
+ session bookkeeping, recipe overlay merging, and a typed session ledger.
8
+
9
+ It is generic. The framework knows nothing about your domain. Engines plug in.
10
+
11
+ ## Install
12
+
13
+ ```
14
+ pip install gooseloop
15
+ ```
16
+
17
+ Or, from a checkout:
18
+
19
+ ```
20
+ pip install -e .
21
+ ```
22
+
23
+ Requires Python 3.11+, the `goose` CLI on `$PATH`, and a YAML recipe directory.
24
+
25
+ ## The shape
26
+
27
+ Three primitives:
28
+
29
+ - **Engine** (verbs). Returns a `Pipeline(review, body, summary)`. Owns
30
+ branching, recipes, scoring, retry rules.
31
+ - **Environment** (nouns). One abstract method (`env_vars()`). Subclasses or
32
+ contrib mixins (`gooseloop.contrib.CustomerPipelineEnvironment`,
33
+ `gooseloop.contrib.ClaudeHandoffEnvironment`) add shape-specific contracts.
34
+ - **Pipeline** (the bookend). Review runs first and emits structured routing.
35
+ Body phases run in queue order (cadence phases + review-spawned branches).
36
+ Summary runs last and reads the final ledger.
37
+
38
+ The contract is documented in [`PROTOCOL.md`](PROTOCOL.md).
39
+ The design history lives in [`docs/adr/`](docs/adr/).
40
+
41
+ ## Five minute tour
42
+
43
+ ```python
44
+ from gooseloop import GooseLooper, Engine, Environment, Phase, Pipeline
45
+
46
+ class HelloEnvironment(Environment):
47
+ def env_vars(self) -> dict[str, str]:
48
+ return {"GREETING": "hello"}
49
+
50
+ class HelloEngine(Engine):
51
+ name = "hello-world"
52
+
53
+ def pipeline(self, ctx) -> Pipeline:
54
+ return Pipeline(
55
+ review=Phase(name="review", recipe_path="recipes/review.yaml"),
56
+ body=[Phase(name="greet", recipe_path="recipes/greet.yaml")],
57
+ summary=Phase(name="summary", recipe_path="recipes/summary.yaml"),
58
+ )
59
+
60
+ GooseLooper(engine=HelloEngine(), environment=HelloEnvironment()).begin_loop()
61
+ ```
62
+
63
+ The framework ships a working version of this engine under `engines/hello_world/`.
64
+
65
+ ## Built-in engines
66
+
67
+ Three reference engines ship in `engines/`. They live alongside the framework,
68
+ not inside the installed wheel, so run them **from a checkout** (the repo root,
69
+ where `engines/` is importable) and select one with `-e <module>`:
70
+
71
+ | Engine | What it does | Run (from the repo root) |
72
+ |--------|--------------|--------------------------|
73
+ | `hello_world` | The minimal reference engine (the tour above). | `python3 -m gooseloop run -e engines.hello_world` |
74
+ | `git_recap` | Summarises your recent commits across configured repos into a changelog. Configure `[git_recap]` in `gooseloop.toml`. | `python3 -m gooseloop run -e engines.git_recap` |
75
+ | `doc_drift` | Finds derived docs/pages that fell behind their canonical source and drafts a patch to seal. Configure `[doc_drift]`, then `cp doc-map.example.toml doc-map.toml` and edit. | `python3 -m gooseloop run -e engines.doc_drift` |
76
+
77
+ Drop `-e` to run whatever `[gooseloop] engine_module` points at (`hello_world`
78
+ by default). The installed `gooseloop` console script is equivalent to
79
+ `python3 -m gooseloop`, but the built-in engines above still need a checkout on
80
+ the path. Add `--review-only` to any of them to stop after the review phase.
81
+
82
+ ## Recipe overlay merge
83
+
84
+ Recipes compose docker-compose style. The looper resolves layers in this order:
85
+
86
+ 1. Base recipe (declared in `gooseloop.toml`).
87
+ 2. `<name>.local.yaml` (gitignored; per-machine).
88
+ 3. `--review-overlay X.yaml` / `--summary-overlay X.yaml` (ad-hoc).
89
+
90
+ Inspect the merged result with `gooseloop recipe --resolve <name>`.
91
+
92
+ Merge rules table is in [PROTOCOL.md §6](PROTOCOL.md).
93
+
94
+ ## Contrib mixins
95
+
96
+ Domain-shaped Environment ABCs ship under `gooseloop.contrib`:
97
+
98
+ - `CustomerPipelineEnvironment`: customer-acquisition pipelines
99
+ (`build_digest()`, `journal_text()`, `lifecycle_dirs()`, ...).
100
+ - `ClaudeHandoffEnvironment`: Claude design-handoff engines
101
+ (`handoff_dir()`, `target_repo()`, `dev_up_probe()`, ...).
102
+
103
+ An engine with no fit subclasses bare `Environment` and writes one method.
104
+
105
+ ## CLI
106
+
107
+ ```
108
+ gooseloop run # run the configured engine, one pass
109
+ gooseloop run --review-only # stop after review
110
+ gooseloop run --review-overlay x.yaml --summary-overlay y.yaml
111
+ gooseloop recipe --resolve review # print fully-merged recipe
112
+ gooseloop engines # list known engines from gooseloop.toml
113
+ ```
114
+
115
+ `gooseloop.toml` at the project root configures which engine, which recipes,
116
+ retry tuning, sessions dir.
117
+
118
+ ## License
119
+
120
+ MIT. See [`LICENSE`](LICENSE).
121
+
122
+ ## Provenance
123
+
124
+ `gooseloop` was extracted from Storm Developments' customer-acquisition pipeline
125
+ in June 2026. Storm's customer engine remains the reference consumer; the
126
+ framework is independent and OSS-first.
@@ -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"
@@ -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())
@@ -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