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.
- {gooseloop-0.1.1 → gooseloop-0.1.2}/PKG-INFO +12 -12
- {gooseloop-0.1.1 → gooseloop-0.1.2}/README.md +11 -11
- {gooseloop-0.1.1 → gooseloop-0.1.2}/gooseloop/__init__.py +20 -2
- gooseloop-0.1.2/gooseloop/__main__.py +340 -0
- gooseloop-0.1.2/gooseloop/boundary.py +287 -0
- {gooseloop-0.1.1 → gooseloop-0.1.2}/gooseloop/branch_policy.py +13 -4
- {gooseloop-0.1.1 → gooseloop-0.1.2}/gooseloop/config.py +65 -3
- {gooseloop-0.1.1 → gooseloop-0.1.2}/gooseloop/context_prepend.py +35 -10
- {gooseloop-0.1.1 → gooseloop-0.1.2}/gooseloop/engine.py +12 -0
- {gooseloop-0.1.1 → gooseloop-0.1.2}/gooseloop/goose.py +57 -3
- gooseloop-0.1.2/gooseloop/guardrails.py +84 -0
- gooseloop-0.1.2/gooseloop/introspect.py +200 -0
- {gooseloop-0.1.1 → gooseloop-0.1.2}/gooseloop/looper.py +377 -15
- {gooseloop-0.1.1 → gooseloop-0.1.2}/gooseloop/protocol.py +25 -1
- gooseloop-0.1.2/gooseloop/runlock.py +182 -0
- {gooseloop-0.1.1 → gooseloop-0.1.2}/gooseloop/session.py +30 -2
- gooseloop-0.1.2/gooseloop/telemetry.py +178 -0
- {gooseloop-0.1.1 → gooseloop-0.1.2}/gooseloop.egg-info/PKG-INFO +12 -12
- {gooseloop-0.1.1 → gooseloop-0.1.2}/gooseloop.egg-info/SOURCES.txt +11 -0
- {gooseloop-0.1.1 → gooseloop-0.1.2}/pyproject.toml +1 -1
- gooseloop-0.1.2/tests/test_boundary.py +318 -0
- {gooseloop-0.1.1 → gooseloop-0.1.2}/tests/test_branch_policy_and_looper.py +270 -11
- {gooseloop-0.1.1 → gooseloop-0.1.2}/tests/test_cli.py +108 -6
- {gooseloop-0.1.1 → gooseloop-0.1.2}/tests/test_config.py +24 -4
- {gooseloop-0.1.1 → gooseloop-0.1.2}/tests/test_context_prepend.py +5 -4
- {gooseloop-0.1.1 → gooseloop-0.1.2}/tests/test_doc_drift.py +316 -26
- gooseloop-0.1.2/tests/test_engine_override.py +105 -0
- gooseloop-0.1.2/tests/test_git_recap.py +376 -0
- {gooseloop-0.1.1 → gooseloop-0.1.2}/tests/test_goose_retry.py +37 -1
- gooseloop-0.1.2/tests/test_guardrails.py +127 -0
- gooseloop-0.1.2/tests/test_hello_world.py +83 -0
- gooseloop-0.1.2/tests/test_introspect.py +199 -0
- {gooseloop-0.1.1 → gooseloop-0.1.2}/tests/test_protocol.py +11 -1
- {gooseloop-0.1.1 → gooseloop-0.1.2}/tests/test_protocol_doc_conformance.py +7 -2
- {gooseloop-0.1.1 → gooseloop-0.1.2}/tests/test_public_api.py +8 -0
- gooseloop-0.1.2/tests/test_runlock.py +272 -0
- gooseloop-0.1.2/tests/test_telemetry.py +417 -0
- gooseloop-0.1.1/gooseloop/__main__.py +0 -181
- gooseloop-0.1.1/tests/test_engine_override.py +0 -40
- gooseloop-0.1.1/tests/test_git_recap.py +0 -390
- {gooseloop-0.1.1 → gooseloop-0.1.2}/LICENSE +0 -0
- {gooseloop-0.1.1 → gooseloop-0.1.2}/gooseloop/artifact.py +0 -0
- {gooseloop-0.1.1 → gooseloop-0.1.2}/gooseloop/contrib/__init__.py +0 -0
- {gooseloop-0.1.1 → gooseloop-0.1.2}/gooseloop/contrib/claude_handoff.py +0 -0
- {gooseloop-0.1.1 → gooseloop-0.1.2}/gooseloop/contrib/customer_pipeline.py +0 -0
- {gooseloop-0.1.1 → gooseloop-0.1.2}/gooseloop/environment.py +0 -0
- {gooseloop-0.1.1 → gooseloop-0.1.2}/gooseloop/extract.py +0 -0
- {gooseloop-0.1.1 → gooseloop-0.1.2}/gooseloop/footer.py +0 -0
- {gooseloop-0.1.1 → gooseloop-0.1.2}/gooseloop/phase.py +0 -0
- {gooseloop-0.1.1 → gooseloop-0.1.2}/gooseloop/predicates.py +0 -0
- {gooseloop-0.1.1 → gooseloop-0.1.2}/gooseloop/py.typed +0 -0
- {gooseloop-0.1.1 → gooseloop-0.1.2}/gooseloop/recipe_merge.py +0 -0
- {gooseloop-0.1.1 → gooseloop-0.1.2}/gooseloop/text.py +0 -0
- {gooseloop-0.1.1 → gooseloop-0.1.2}/gooseloop/toolkit.py +0 -0
- {gooseloop-0.1.1 → gooseloop-0.1.2}/gooseloop.egg-info/dependency_links.txt +0 -0
- {gooseloop-0.1.1 → gooseloop-0.1.2}/gooseloop.egg-info/entry_points.txt +0 -0
- {gooseloop-0.1.1 → gooseloop-0.1.2}/gooseloop.egg-info/requires.txt +0 -0
- {gooseloop-0.1.1 → gooseloop-0.1.2}/gooseloop.egg-info/top_level.txt +0 -0
- {gooseloop-0.1.1 → gooseloop-0.1.2}/setup.cfg +0 -0
- {gooseloop-0.1.1 → gooseloop-0.1.2}/tests/test_artifact.py +0 -0
- {gooseloop-0.1.1 → gooseloop-0.1.2}/tests/test_extract.py +0 -0
- {gooseloop-0.1.1 → gooseloop-0.1.2}/tests/test_goose_failure_modes.py +0 -0
- {gooseloop-0.1.1 → gooseloop-0.1.2}/tests/test_phase_and_pipeline.py +0 -0
- {gooseloop-0.1.1 → gooseloop-0.1.2}/tests/test_predicates.py +0 -0
- {gooseloop-0.1.1 → gooseloop-0.1.2}/tests/test_recipe_merge.py +0 -0
- {gooseloop-0.1.1 → gooseloop-0.1.2}/tests/test_session.py +0 -0
- {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.
|
|
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` |
|
|
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
|
-
|
|
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
|
|
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
|
|
146
|
+
gooseloop engines # list every engine in the loop root
|
|
143
147
|
```
|
|
144
148
|
|
|
145
|
-
`gooseloop.toml` at the project root configures
|
|
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` |
|
|
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
|
-
|
|
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
|
|
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
|
|
121
|
+
gooseloop engines # list every engine in the loop root
|
|
118
122
|
```
|
|
119
123
|
|
|
120
|
-
`gooseloop.toml` at the project root configures
|
|
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.
|
|
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())
|