gooseloop 0.1.1__tar.gz → 0.1.2__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 (67) hide show
  1. {gooseloop-0.1.1 → gooseloop-0.1.2}/PKG-INFO +12 -12
  2. {gooseloop-0.1.1 → gooseloop-0.1.2}/README.md +11 -11
  3. {gooseloop-0.1.1 → gooseloop-0.1.2}/gooseloop/__init__.py +20 -2
  4. gooseloop-0.1.2/gooseloop/__main__.py +340 -0
  5. gooseloop-0.1.2/gooseloop/boundary.py +287 -0
  6. {gooseloop-0.1.1 → gooseloop-0.1.2}/gooseloop/branch_policy.py +13 -4
  7. {gooseloop-0.1.1 → gooseloop-0.1.2}/gooseloop/config.py +65 -3
  8. {gooseloop-0.1.1 → gooseloop-0.1.2}/gooseloop/context_prepend.py +35 -10
  9. {gooseloop-0.1.1 → gooseloop-0.1.2}/gooseloop/engine.py +12 -0
  10. {gooseloop-0.1.1 → gooseloop-0.1.2}/gooseloop/goose.py +57 -3
  11. gooseloop-0.1.2/gooseloop/guardrails.py +84 -0
  12. gooseloop-0.1.2/gooseloop/introspect.py +200 -0
  13. {gooseloop-0.1.1 → gooseloop-0.1.2}/gooseloop/looper.py +377 -15
  14. {gooseloop-0.1.1 → gooseloop-0.1.2}/gooseloop/protocol.py +25 -1
  15. gooseloop-0.1.2/gooseloop/runlock.py +182 -0
  16. {gooseloop-0.1.1 → gooseloop-0.1.2}/gooseloop/session.py +30 -2
  17. gooseloop-0.1.2/gooseloop/telemetry.py +178 -0
  18. {gooseloop-0.1.1 → gooseloop-0.1.2}/gooseloop.egg-info/PKG-INFO +12 -12
  19. {gooseloop-0.1.1 → gooseloop-0.1.2}/gooseloop.egg-info/SOURCES.txt +11 -0
  20. {gooseloop-0.1.1 → gooseloop-0.1.2}/pyproject.toml +1 -1
  21. gooseloop-0.1.2/tests/test_boundary.py +318 -0
  22. {gooseloop-0.1.1 → gooseloop-0.1.2}/tests/test_branch_policy_and_looper.py +270 -11
  23. {gooseloop-0.1.1 → gooseloop-0.1.2}/tests/test_cli.py +108 -6
  24. {gooseloop-0.1.1 → gooseloop-0.1.2}/tests/test_config.py +24 -4
  25. {gooseloop-0.1.1 → gooseloop-0.1.2}/tests/test_context_prepend.py +5 -4
  26. {gooseloop-0.1.1 → gooseloop-0.1.2}/tests/test_doc_drift.py +316 -26
  27. gooseloop-0.1.2/tests/test_engine_override.py +105 -0
  28. gooseloop-0.1.2/tests/test_git_recap.py +376 -0
  29. {gooseloop-0.1.1 → gooseloop-0.1.2}/tests/test_goose_retry.py +37 -1
  30. gooseloop-0.1.2/tests/test_guardrails.py +127 -0
  31. gooseloop-0.1.2/tests/test_hello_world.py +83 -0
  32. gooseloop-0.1.2/tests/test_introspect.py +199 -0
  33. {gooseloop-0.1.1 → gooseloop-0.1.2}/tests/test_protocol.py +11 -1
  34. {gooseloop-0.1.1 → gooseloop-0.1.2}/tests/test_protocol_doc_conformance.py +7 -2
  35. {gooseloop-0.1.1 → gooseloop-0.1.2}/tests/test_public_api.py +8 -0
  36. gooseloop-0.1.2/tests/test_runlock.py +272 -0
  37. gooseloop-0.1.2/tests/test_telemetry.py +417 -0
  38. gooseloop-0.1.1/gooseloop/__main__.py +0 -181
  39. gooseloop-0.1.1/tests/test_engine_override.py +0 -40
  40. gooseloop-0.1.1/tests/test_git_recap.py +0 -390
  41. {gooseloop-0.1.1 → gooseloop-0.1.2}/LICENSE +0 -0
  42. {gooseloop-0.1.1 → gooseloop-0.1.2}/gooseloop/artifact.py +0 -0
  43. {gooseloop-0.1.1 → gooseloop-0.1.2}/gooseloop/contrib/__init__.py +0 -0
  44. {gooseloop-0.1.1 → gooseloop-0.1.2}/gooseloop/contrib/claude_handoff.py +0 -0
  45. {gooseloop-0.1.1 → gooseloop-0.1.2}/gooseloop/contrib/customer_pipeline.py +0 -0
  46. {gooseloop-0.1.1 → gooseloop-0.1.2}/gooseloop/environment.py +0 -0
  47. {gooseloop-0.1.1 → gooseloop-0.1.2}/gooseloop/extract.py +0 -0
  48. {gooseloop-0.1.1 → gooseloop-0.1.2}/gooseloop/footer.py +0 -0
  49. {gooseloop-0.1.1 → gooseloop-0.1.2}/gooseloop/phase.py +0 -0
  50. {gooseloop-0.1.1 → gooseloop-0.1.2}/gooseloop/predicates.py +0 -0
  51. {gooseloop-0.1.1 → gooseloop-0.1.2}/gooseloop/py.typed +0 -0
  52. {gooseloop-0.1.1 → gooseloop-0.1.2}/gooseloop/recipe_merge.py +0 -0
  53. {gooseloop-0.1.1 → gooseloop-0.1.2}/gooseloop/text.py +0 -0
  54. {gooseloop-0.1.1 → gooseloop-0.1.2}/gooseloop/toolkit.py +0 -0
  55. {gooseloop-0.1.1 → gooseloop-0.1.2}/gooseloop.egg-info/dependency_links.txt +0 -0
  56. {gooseloop-0.1.1 → gooseloop-0.1.2}/gooseloop.egg-info/entry_points.txt +0 -0
  57. {gooseloop-0.1.1 → gooseloop-0.1.2}/gooseloop.egg-info/requires.txt +0 -0
  58. {gooseloop-0.1.1 → gooseloop-0.1.2}/gooseloop.egg-info/top_level.txt +0 -0
  59. {gooseloop-0.1.1 → gooseloop-0.1.2}/setup.cfg +0 -0
  60. {gooseloop-0.1.1 → gooseloop-0.1.2}/tests/test_artifact.py +0 -0
  61. {gooseloop-0.1.1 → gooseloop-0.1.2}/tests/test_extract.py +0 -0
  62. {gooseloop-0.1.1 → gooseloop-0.1.2}/tests/test_goose_failure_modes.py +0 -0
  63. {gooseloop-0.1.1 → gooseloop-0.1.2}/tests/test_phase_and_pipeline.py +0 -0
  64. {gooseloop-0.1.1 → gooseloop-0.1.2}/tests/test_predicates.py +0 -0
  65. {gooseloop-0.1.1 → gooseloop-0.1.2}/tests/test_recipe_merge.py +0 -0
  66. {gooseloop-0.1.1 → gooseloop-0.1.2}/tests/test_session.py +0 -0
  67. {gooseloop-0.1.1 → gooseloop-0.1.2}/tests/test_toolkit.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: gooseloop
3
- Version: 0.1.1
3
+ Version: 0.1.2
4
4
  Summary: Execution shell for goose-recipe pipelines
5
5
  Author: Storm Developments
6
6
  License-Expression: Apache-2.0
@@ -101,10 +101,13 @@ where `engines/` is importable) and select one with `-e <module>`:
101
101
  | Engine | What it does | Run (from the repo root) |
102
102
  |--------|--------------|--------------------------|
103
103
  | `hello_world` | The minimal reference engine (the tour above). | `python3 -m gooseloop run -e engines.hello_world` |
104
- | `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` |
104
+ | `git_recap` | Keeps a work journal across configured repos: one combined daily entry per date (commits since each repo's watermark), plus a weekly review when an ISO week closes. Configure `[git_recap]` in `gooseloop.toml`. | `python3 -m gooseloop run git_recap` |
105
105
  | `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` |
106
106
 
107
- Drop `-e` to run whatever `[gooseloop] engine_module` points at (`hello_world`
107
+ Engines can also be run by short name — `python3 -m gooseloop run doc_drift`
108
+ resolves to `engines.doc_drift` by scanning the loop root's packages, and
109
+ `python3 -m gooseloop engines` lists everything it finds. Drop the engine
110
+ argument to run whatever `[gooseloop] default_engine` points at (`hello_world`
108
111
  by default). The installed `gooseloop` console script is equivalent to
109
112
  `python3 -m gooseloop`, but the built-in engines above still need a checkout on
110
113
  the path. Add `--review-only` to any of them to stop after the review phase.
@@ -135,22 +138,19 @@ An engine with no fit subclasses bare `Environment` and writes one method.
135
138
  ## CLI
136
139
 
137
140
  ```
138
- gooseloop run # run the configured engine, one pass
141
+ gooseloop run # run the default engine, one pass
142
+ gooseloop run doc_drift # run any engine by short name
139
143
  gooseloop run --review-only # stop after review
140
144
  gooseloop run --review-overlay x.yaml --summary-overlay y.yaml
141
145
  gooseloop recipe --resolve review # print fully-merged recipe
142
- gooseloop engines # list known engines from gooseloop.toml
146
+ gooseloop engines # list every engine in the loop root
143
147
  ```
144
148
 
145
- `gooseloop.toml` at the project root configures which engine, which recipes,
146
- retry tuning, sessions dir.
149
+ `gooseloop.toml` at the project root configures the default engine, recipes,
150
+ retry tuning, sessions dir. One loop root can host many engines; the default
151
+ is only what a bare `gooseloop run` runs.
147
152
 
148
153
  ## License
149
154
 
150
155
  Apache 2.0, matching the goose layer it builds on. See [`LICENSE`](LICENSE) and [`NOTICE`](NOTICE).
151
156
 
152
- ## Provenance
153
-
154
- `gooseloop` was extracted from Storm Developments' customer-acquisition pipeline
155
- in June 2026. Storm's customer engine remains the reference consumer; the
156
- framework is independent and OSS-first.
@@ -76,10 +76,13 @@ where `engines/` is importable) and select one with `-e <module>`:
76
76
  | Engine | What it does | Run (from the repo root) |
77
77
  |--------|--------------|--------------------------|
78
78
  | `hello_world` | The minimal reference engine (the tour above). | `python3 -m gooseloop run -e engines.hello_world` |
79
- | `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` |
79
+ | `git_recap` | Keeps a work journal across configured repos: one combined daily entry per date (commits since each repo's watermark), plus a weekly review when an ISO week closes. Configure `[git_recap]` in `gooseloop.toml`. | `python3 -m gooseloop run git_recap` |
80
80
  | `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` |
81
81
 
82
- Drop `-e` to run whatever `[gooseloop] engine_module` points at (`hello_world`
82
+ Engines can also be run by short name — `python3 -m gooseloop run doc_drift`
83
+ resolves to `engines.doc_drift` by scanning the loop root's packages, and
84
+ `python3 -m gooseloop engines` lists everything it finds. Drop the engine
85
+ argument to run whatever `[gooseloop] default_engine` points at (`hello_world`
83
86
  by default). The installed `gooseloop` console script is equivalent to
84
87
  `python3 -m gooseloop`, but the built-in engines above still need a checkout on
85
88
  the path. Add `--review-only` to any of them to stop after the review phase.
@@ -110,22 +113,19 @@ An engine with no fit subclasses bare `Environment` and writes one method.
110
113
  ## CLI
111
114
 
112
115
  ```
113
- gooseloop run # run the configured engine, one pass
116
+ gooseloop run # run the default engine, one pass
117
+ gooseloop run doc_drift # run any engine by short name
114
118
  gooseloop run --review-only # stop after review
115
119
  gooseloop run --review-overlay x.yaml --summary-overlay y.yaml
116
120
  gooseloop recipe --resolve review # print fully-merged recipe
117
- gooseloop engines # list known engines from gooseloop.toml
121
+ gooseloop engines # list every engine in the loop root
118
122
  ```
119
123
 
120
- `gooseloop.toml` at the project root configures which engine, which recipes,
121
- retry tuning, sessions dir.
124
+ `gooseloop.toml` at the project root configures the default engine, recipes,
125
+ retry tuning, sessions dir. One loop root can host many engines; the default
126
+ is only what a bare `gooseloop run` runs.
122
127
 
123
128
  ## License
124
129
 
125
130
  Apache 2.0, matching the goose layer it builds on. See [`LICENSE`](LICENSE) and [`NOTICE`](NOTICE).
126
131
 
127
- ## Provenance
128
-
129
- `gooseloop` was extracted from Storm Developments' customer-acquisition pipeline
130
- in June 2026. Storm's customer engine remains the reference consumer; the
131
- framework is independent and OSS-first.
@@ -10,33 +10,51 @@ Public surface:
10
10
  Context - passed to phase callables; typed ledger methods
11
11
  BranchPolicy - per-recipe rules for routing[] -> Phase building
12
12
  LooperConfig - resolved gooseloop.toml as a value object
13
+ RunLockHeldError - raised when the loop root's run.lock is held
14
+ RUN_LOCK_FILENAME - "run.lock", the per-root lock file (PROTOCOL 13)
15
+ BoundaryUnavailableError - .gooseignore present, bubblewrap missing
16
+ GOOSEIGNORE_FILENAME - ".gooseignore", the boundary file (PROTOCOL 15)
17
+ boundary - THE BOUNDARY: bwrap masking around goose spawns
18
+ guardrails - egress tripwire: scan_and_redact secret-shaped output
19
+ introspect - env_method listing + context-source dry-run preview
20
+ telemetry - phases.jsonl wide-event reader (PROTOCOL 14)
13
21
  predicates - success_predicate factories
14
22
  protocol - ReviewOutput / OperatorAction / RoutingEntry types
15
23
  toolkit - stdlib-only engine helpers (Source, fetch_url, state io)
16
24
  artifact - versioned artifact contracts for engine composition
17
25
  """
18
26
 
19
- from . import artifact, predicates, protocol, toolkit
27
+ from . import artifact, boundary, guardrails, introspect, predicates, protocol, telemetry, toolkit
28
+ from .boundary import GOOSEIGNORE_FILENAME, BoundaryUnavailableError
20
29
  from .branch_policy import BranchPolicy
21
30
  from .config import LooperConfig
22
31
  from .engine import Engine
23
32
  from .environment import Environment
24
33
  from .looper import GooseLooper
25
34
  from .phase import Context, Phase, Pipeline
35
+ from .runlock import RUN_LOCK_FILENAME, RunLockHeldError
26
36
 
27
37
  __all__ = [
38
+ "BoundaryUnavailableError",
28
39
  "BranchPolicy",
29
40
  "Context",
30
41
  "Engine",
31
42
  "Environment",
43
+ "GOOSEIGNORE_FILENAME",
32
44
  "GooseLooper",
33
45
  "LooperConfig",
34
46
  "Phase",
35
47
  "Pipeline",
48
+ "RUN_LOCK_FILENAME",
49
+ "RunLockHeldError",
36
50
  "artifact",
51
+ "boundary",
52
+ "guardrails",
53
+ "introspect",
37
54
  "predicates",
38
55
  "protocol",
56
+ "telemetry",
39
57
  "toolkit",
40
58
  ]
41
59
 
42
- __version__ = "0.1.1"
60
+ __version__ = "0.1.2"
@@ -0,0 +1,340 @@
1
+ """`gooseloop` CLI entry point.
2
+
3
+ Subcommands:
4
+ run [ENGINE] Run an engine, one pass. ENGINE is a short
5
+ name (`doc_drift`) or dotted module path;
6
+ omitted = gooseloop.toml's default_engine.
7
+ --review-only Stop after the review phase.
8
+ --review-overlay PATH Stack a review-recipe overlay (repeatable).
9
+ --summary-overlay PATH Stack a summary-recipe overlay (repeatable).
10
+ --no-save Don't write a session folder.
11
+ --no-validate Skip engine.precheck().
12
+ --model NAME Override the configured model.
13
+
14
+ recipe --resolve NAME Print the fully-merged recipe NAME.
15
+ recipe --sources NAME Dry-run the recipe's context sources and
16
+ list the env_methods / env vars in scope.
17
+ --json Machine-readable output (for dashboards).
18
+ -e MODULE Engine whose Environment supplies the scope.
19
+
20
+ engines List every engine in the loop root.
21
+
22
+ Engine loading: a name resolves to a dotted module path per ADR 0009
23
+ (short names scan the loop root's top-level packages), the module is
24
+ imported, and its module-level `engine` attribute is instantiated. Engines
25
+ expose their class as a module-level callable in their __init__.py.
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import argparse
31
+ import dataclasses
32
+ import importlib
33
+ import json
34
+ import os
35
+ import sys
36
+ from pathlib import Path
37
+ from typing import Any
38
+
39
+ from .boundary import EXIT_BOUNDARY, BoundaryUnavailableError
40
+ from .config import LooperConfig, resolve_engine_module
41
+ from .engine import Engine
42
+ from .environment import Environment
43
+ from .introspect import list_env_methods, preview_recipe_context
44
+ from .looper import GooseLooper
45
+ from .runlock import EXIT_LOCKED, RunLockHeldError
46
+ from .recipe_merge import load_layered_recipe, resolved_recipe_yaml
47
+ from .text import Color, colored
48
+
49
+
50
+ def main(argv: list[str] | None = None) -> int:
51
+ parser = argparse.ArgumentParser(prog="gooseloop")
52
+ sub = parser.add_subparsers(dest="cmd", required=True)
53
+
54
+ run = sub.add_parser("run", help="run an engine, one pass")
55
+ run.add_argument("engine_name", nargs="?", default=None, metavar="ENGINE",
56
+ help="engine to run: a short name (doc_drift) or dotted "
57
+ "module path (default: gooseloop.toml's default_engine)")
58
+ run.add_argument("-e", "--engine", default=None, metavar="MODULE",
59
+ help="same as the positional ENGINE (kept for compatibility)")
60
+ run.add_argument("--review-only", action="store_true",
61
+ help="stop after the review phase")
62
+ run.add_argument("--review-overlay", action="append", default=[],
63
+ metavar="PATH", help="stack a review-recipe overlay")
64
+ run.add_argument("--summary-overlay", action="append", default=[],
65
+ metavar="PATH", help="stack a summary-recipe overlay")
66
+ run.add_argument("--no-save", action="store_true",
67
+ help="do not write a session folder")
68
+ run.add_argument("--no-validate", action="store_true",
69
+ help="skip engine.precheck()")
70
+ run.add_argument("--model", default=None, help="override the configured model")
71
+
72
+ rec = sub.add_parser("recipe", help="recipe utilities")
73
+ rec.add_argument("--resolve", metavar="NAME",
74
+ help="print the fully-merged recipe NAME")
75
+ rec.add_argument("--sources", metavar="NAME",
76
+ help="dry-run NAME's context sources; list env_methods "
77
+ "and env vars in scope")
78
+ rec.add_argument("--json", action="store_true",
79
+ help="machine-readable --sources output")
80
+ rec.add_argument("-e", "--engine", default=None, metavar="MODULE",
81
+ help="engine whose Environment supplies the --sources "
82
+ "scope (default: gooseloop.toml's default_engine)")
83
+ rec.add_argument("--overlay", action="append", default=[], metavar="PATH",
84
+ help="extra overlay path to include in the merge")
85
+
86
+ sub.add_parser("engines", help="list every engine in the loop root")
87
+
88
+ args = parser.parse_args(argv)
89
+
90
+ if args.cmd == "run":
91
+ return _cmd_run(args)
92
+ if args.cmd == "recipe":
93
+ return _cmd_recipe(args)
94
+ if args.cmd == "engines":
95
+ return _cmd_engines(args)
96
+ parser.error(f"unknown command: {args.cmd}")
97
+ return 2 # unreachable
98
+
99
+
100
+ def _cmd_run(args: argparse.Namespace) -> int:
101
+ config = LooperConfig.load()
102
+ requested = args.engine_name or args.engine
103
+ engine, environment, module_name = _load_engine_and_environment(config, engine_override=requested)
104
+ looper = GooseLooper(
105
+ engine=engine,
106
+ environment=environment,
107
+ engine_module=module_name,
108
+ config=config,
109
+ model=args.model,
110
+ save=not args.no_save,
111
+ validate=not args.no_validate,
112
+ review_only=args.review_only,
113
+ review_overlays=[Path(p) for p in args.review_overlay],
114
+ summary_overlays=[Path(p) for p in args.summary_overlay],
115
+ )
116
+ try:
117
+ result = looper.begin_loop()
118
+ except RunLockHeldError as e:
119
+ # Exit 3 = "busy", distinct from 1 (run error) and 2 (usage), so
120
+ # a supervisor can tell a held lock from a failed run.
121
+ print(f"gooseloop: {e}", file=sys.stderr)
122
+ return EXIT_LOCKED
123
+ except BoundaryUnavailableError as e:
124
+ # Exit 4 = "boundary refused": a .gooseignore demands a sandbox
125
+ # bubblewrap cannot provide (PROTOCOL section 15).
126
+ print(f"gooseloop: {e}", file=sys.stderr)
127
+ return EXIT_BOUNDARY
128
+ return 0 if result.get("review_status") != "error" else 1
129
+
130
+
131
+ def _cmd_recipe(args: argparse.Namespace) -> int:
132
+ if args.sources:
133
+ return _cmd_recipe_sources(args)
134
+ if not args.resolve:
135
+ print("usage: gooseloop recipe --resolve NAME | --sources NAME "
136
+ "[--json] [--overlay PATH ...]",
137
+ file=sys.stderr)
138
+ return 2
139
+ config = LooperConfig.load()
140
+ loaded = _load_merged_recipe(config, args.resolve, args.overlay)
141
+ if loaded is None:
142
+ return 1
143
+ _, merged = loaded
144
+ print(resolved_recipe_yaml(merged))
145
+ return 0
146
+
147
+
148
+ def _load_merged_recipe(
149
+ config: LooperConfig, name: str, overlays: list[str],
150
+ ) -> tuple[Path, dict[str, Any]] | None:
151
+ """Resolve NAME to a recipe file and merge base + local + overlays.
152
+
153
+ Accepts a bare name ("review") or a path with suffix. Prints the
154
+ not-found error itself; None means the caller exits 1.
155
+ """
156
+ base_path = (config.anchor / name).resolve()
157
+ if not base_path.exists():
158
+ candidate = base_path.with_suffix(".yaml")
159
+ if candidate.exists():
160
+ base_path = candidate
161
+ else:
162
+ print(f"recipe not found: {base_path}", file=sys.stderr)
163
+ return None
164
+ local = base_path.with_name(base_path.stem + ".local" + base_path.suffix)
165
+ merged = load_layered_recipe(
166
+ base_path,
167
+ local_path=local if local.exists() else None,
168
+ overlay_paths=[Path(p) for p in overlays],
169
+ )
170
+ return base_path, merged
171
+
172
+
173
+ def _cmd_recipe_sources(args: argparse.Namespace) -> int:
174
+ """Dry-run a recipe's context sources; list the env scope (PROTOCOL §7).
175
+
176
+ Exit code 0 when every required source resolves (optional failures
177
+ are reported but tolerated, matching render-time strictness); 1 when
178
+ a required source would fail the render.
179
+ """
180
+ config = LooperConfig.load()
181
+ engine, environment, _module = _load_engine_and_environment(
182
+ config, engine_override=args.engine,
183
+ )
184
+ loaded = _load_merged_recipe(config, args.sources, args.overlay)
185
+ if loaded is None:
186
+ return 1
187
+ base_path, merged = loaded
188
+
189
+ scope = {
190
+ **(environment.env_vars() if environment else {}),
191
+ **engine.base_env(),
192
+ }
193
+ env = {**os.environ, **scope}
194
+ previews = preview_recipe_context(merged, env, environment=environment,
195
+ injected_env=engine.injected_env())
196
+ methods = list_env_methods(environment)
197
+ failures = [p for p in previews if not p.preview.ok and not p.optional]
198
+
199
+ if args.json:
200
+ payload = {
201
+ "recipe": str(base_path),
202
+ "context": [
203
+ {"label": p.label, "optional": p.optional,
204
+ **dataclasses.asdict(p.preview)}
205
+ for p in previews
206
+ ],
207
+ "env_methods": [dataclasses.asdict(m) for m in methods],
208
+ "env_vars": sorted(scope),
209
+ "ok": not failures,
210
+ }
211
+ print(json.dumps(payload, indent=2))
212
+ return 0 if not failures else 1
213
+
214
+ print(f"{base_path.name} · {len(previews)} context source(s)")
215
+ for p in previews:
216
+ mark = (colored("ok ", Color.GREEN) if p.preview.ok
217
+ else colored("FAIL", Color.RED))
218
+ opt = " [optional]" if p.optional else ""
219
+ print(f" {mark} {p.label:<20} {p.preview.source}{opt}")
220
+ print(f" -> {p.preview.resolved or '(unresolved)'}: {p.preview.detail}")
221
+ for m in p.preview.matches[:8]:
222
+ print(f" {m.path} ({m.size} bytes)")
223
+ if len(p.preview.matches) > 8:
224
+ print(f" ... and {len(p.preview.matches) - 8} more")
225
+ if methods:
226
+ print("env_method sources available:")
227
+ for method in methods:
228
+ doc = f" — {method.doc}" if method.doc else ""
229
+ print(f" {method.name}{doc}")
230
+ if scope:
231
+ print(f"env vars in scope: {', '.join(sorted(scope))}")
232
+ return 0 if not failures else 1
233
+
234
+
235
+ def _cmd_engines(args: argparse.Namespace) -> int:
236
+ """List every engine in the loop root, not just the default — one
237
+ gooseloop.toml routinely serves several engines (ADR 0009)."""
238
+ config = LooperConfig.load()
239
+ try:
240
+ default_module = resolve_engine_module(config.anchor, config.default_engine)
241
+ except LookupError as e:
242
+ print(f"gooseloop: {e}", file=sys.stderr)
243
+ return 1
244
+
245
+ sys.path.insert(0, str(config.anchor))
246
+ try:
247
+ found = 0
248
+ for module_name in _candidate_engine_modules(config.anchor, default_module):
249
+ try:
250
+ mod = importlib.import_module(module_name)
251
+ except ImportError:
252
+ continue
253
+ engine_obj = getattr(mod, "engine", None)
254
+ if engine_obj is None:
255
+ continue
256
+ found += 1
257
+ short = module_name.rsplit(".", 1)[-1]
258
+ marker = " (default)" if module_name == default_module else ""
259
+ print(f"{short:20s} {module_name}{marker}")
260
+ if found == 0:
261
+ print(f"no engines found under {config.anchor}", file=sys.stderr)
262
+ return 1
263
+ finally:
264
+ sys.path.remove(str(config.anchor))
265
+ return 0
266
+
267
+
268
+ def _candidate_engine_modules(anchor: Path, default_module: str) -> list[str]:
269
+ """Sibling packages of the default engine's parent package — the same
270
+ convention resolve_engine_module scans, from the other direction."""
271
+ if "." not in default_module:
272
+ return [default_module]
273
+ parent_pkg = default_module.rsplit(".", 1)[0]
274
+ parent_dir = anchor / Path(*parent_pkg.split("."))
275
+ if not parent_dir.is_dir():
276
+ return [default_module]
277
+ out = []
278
+ for child in sorted(parent_dir.iterdir()):
279
+ if child.is_dir() and (child / "__init__.py").exists():
280
+ out.append(f"{parent_pkg}.{child.name}")
281
+ return out or [default_module]
282
+
283
+
284
+ def _load_engine_and_environment(
285
+ config: LooperConfig,
286
+ *,
287
+ engine_override: str | None = None,
288
+ ) -> tuple[Engine, Environment | None, str]:
289
+ """Resolve an engine name, import it, instantiate its `engine` attribute.
290
+
291
+ Returns (engine, environment, resolved_module_name). The resolved name
292
+ is what run.lock and session.meta.json record (ADR 0010) — the package
293
+ the operator ran, not the submodule the class happens to live in.
294
+
295
+ Convention: an engine package exposes a module-level `engine`
296
+ callable (often the class itself) and optionally an `environment`
297
+ callable. Both are instantiated with no arguments by default.
298
+ Engines requiring constructor arguments should ship factory
299
+ callables in those slots.
300
+
301
+ `engine_override` (the positional ENGINE or `-e`) supersedes
302
+ `config.default_engine` when provided. Either may be a short name
303
+ (resolved per ADR 0009) or a dotted module path.
304
+ """
305
+ requested = engine_override or config.default_engine
306
+ try:
307
+ module_name = resolve_engine_module(config.anchor, requested)
308
+ except LookupError as e:
309
+ raise SystemExit(f"gooseloop: {e}")
310
+ sys.path.insert(0, str(config.anchor))
311
+ try:
312
+ mod = importlib.import_module(module_name)
313
+ except ImportError as e:
314
+ raise SystemExit(
315
+ f"gooseloop: cannot import engine module {module_name!r}: {e}"
316
+ )
317
+ engine_obj = getattr(mod, "engine", None)
318
+ if engine_obj is None:
319
+ raise SystemExit(
320
+ f"gooseloop: engine module {module_name!r} has no `engine` attribute"
321
+ )
322
+ engine = engine_obj() if callable(engine_obj) and not isinstance(engine_obj, Engine) else engine_obj
323
+ if not isinstance(engine, Engine):
324
+ raise SystemExit(
325
+ f"gooseloop: {module_name}.engine did not yield an Engine instance"
326
+ )
327
+
328
+ env_obj = getattr(mod, "environment", None)
329
+ environment: Environment | None = None
330
+ if env_obj is not None:
331
+ environment = env_obj() if callable(env_obj) and not isinstance(env_obj, Environment) else env_obj
332
+ if not isinstance(environment, Environment):
333
+ raise SystemExit(
334
+ f"gooseloop: {module_name}.environment did not yield an Environment instance"
335
+ )
336
+ return engine, environment, module_name
337
+
338
+
339
+ if __name__ == "__main__":
340
+ sys.exit(main())