gooseloop 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- gooseloop/__init__.py +42 -0
- gooseloop/__main__.py +181 -0
- gooseloop/artifact.py +82 -0
- gooseloop/branch_policy.py +49 -0
- gooseloop/config.py +104 -0
- gooseloop/context_prepend.py +316 -0
- gooseloop/contrib/__init__.py +18 -0
- gooseloop/contrib/claude_handoff.py +74 -0
- gooseloop/contrib/customer_pipeline.py +98 -0
- gooseloop/engine.py +68 -0
- gooseloop/environment.py +31 -0
- gooseloop/extract.py +200 -0
- gooseloop/footer.py +75 -0
- gooseloop/goose.py +340 -0
- gooseloop/looper.py +581 -0
- gooseloop/phase.py +146 -0
- gooseloop/predicates.py +69 -0
- gooseloop/protocol.py +197 -0
- gooseloop/recipe_merge.py +152 -0
- gooseloop/session.py +41 -0
- gooseloop/text.py +60 -0
- gooseloop/toolkit.py +243 -0
- gooseloop-0.1.0.dist-info/METADATA +169 -0
- gooseloop-0.1.0.dist-info/RECORD +28 -0
- gooseloop-0.1.0.dist-info/WHEEL +5 -0
- gooseloop-0.1.0.dist-info/entry_points.txt +2 -0
- gooseloop-0.1.0.dist-info/licenses/LICENSE +21 -0
- gooseloop-0.1.0.dist-info/top_level.txt +1 -0
gooseloop/phase.py
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
"""Core data types: Phase, Pipeline, Context.
|
|
2
|
+
|
|
3
|
+
See ADRs 0001, 0006, 0007 and PROTOCOL.md for the contracts these
|
|
4
|
+
types implement. The Pipeline is the bookend dataclass with named review /
|
|
5
|
+
body / summary slots; Context carries typed methods for body phases to
|
|
6
|
+
extend the session ledger.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import TYPE_CHECKING, Any, Callable, Optional
|
|
12
|
+
|
|
13
|
+
from .protocol import OperatorAction, RoutingEntry
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
from .environment import Environment
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class Context:
|
|
21
|
+
"""Carried through a single begin_loop() pass.
|
|
22
|
+
|
|
23
|
+
`session_dir` is None when the Looper was constructed with save=False.
|
|
24
|
+
`artifacts` is the engine's scratchpad; engines store extension data
|
|
25
|
+
here and read each other's contributions across phases. The framework
|
|
26
|
+
reserves a few well-known keys:
|
|
27
|
+
|
|
28
|
+
artifacts["review_output"] -> dict (the parsed ReviewOutput payload)
|
|
29
|
+
artifacts["operator_actions"] -> list[OperatorAction]
|
|
30
|
+
artifacts["outputs_written"] -> list[str]
|
|
31
|
+
|
|
32
|
+
Body phases should mutate these via the typed methods below
|
|
33
|
+
(add_operator_action / record_output / session_log) rather than poking
|
|
34
|
+
artifacts directly; the methods enforce the schema.
|
|
35
|
+
"""
|
|
36
|
+
model: str
|
|
37
|
+
session_dir: Optional[Path]
|
|
38
|
+
base_env: dict[str, str]
|
|
39
|
+
artifacts: dict[str, Any] = field(default_factory=dict)
|
|
40
|
+
environment: Optional["Environment"] = None
|
|
41
|
+
|
|
42
|
+
def add_operator_action(self, action: str, why: str = "", **extras: Any) -> None:
|
|
43
|
+
"""Append an operator action to the session ledger.
|
|
44
|
+
|
|
45
|
+
Dedup is by (action, why); calling twice with the same pair is a
|
|
46
|
+
no-op so cadence phases that re-detect the same condition do not
|
|
47
|
+
flood the ledger. Extras are stored alongside.
|
|
48
|
+
|
|
49
|
+
`action` must be a non-empty string. `why` is optional and may
|
|
50
|
+
be empty — some actions don't need a stated reason; the action
|
|
51
|
+
itself is the operator-facing artifact.
|
|
52
|
+
"""
|
|
53
|
+
if not isinstance(action, str) or not action:
|
|
54
|
+
raise TypeError("add_operator_action: 'action' must be a non-empty str")
|
|
55
|
+
if not isinstance(why, str):
|
|
56
|
+
raise TypeError("add_operator_action: 'why' must be a str (may be empty)")
|
|
57
|
+
ledger: list[OperatorAction] = self.artifacts.setdefault("operator_actions", [])
|
|
58
|
+
for existing in ledger:
|
|
59
|
+
if existing.get("action") == action and existing.get("why") == why:
|
|
60
|
+
return
|
|
61
|
+
entry: OperatorAction = {"action": action, "why": why}
|
|
62
|
+
entry.update(extras) # type: ignore[typeddict-item]
|
|
63
|
+
ledger.append(entry)
|
|
64
|
+
|
|
65
|
+
@property
|
|
66
|
+
def operator_actions(self) -> list[OperatorAction]:
|
|
67
|
+
"""Read-only view of the current ledger. Mutate via add_operator_action."""
|
|
68
|
+
return list(self.artifacts.get("operator_actions", []))
|
|
69
|
+
|
|
70
|
+
def record_output(self, path: Path | str) -> None:
|
|
71
|
+
"""Track a file body phases produced. Summary and footer render these."""
|
|
72
|
+
outputs: list[str] = self.artifacts.setdefault("outputs_written", [])
|
|
73
|
+
as_str = str(path)
|
|
74
|
+
if as_str not in outputs:
|
|
75
|
+
outputs.append(as_str)
|
|
76
|
+
|
|
77
|
+
def session_log(self, message: str) -> None:
|
|
78
|
+
"""Append a timestamped line to the session log, if a session is open."""
|
|
79
|
+
if self.session_dir is None:
|
|
80
|
+
return
|
|
81
|
+
from .session import log_step
|
|
82
|
+
log_step(self.session_dir, message)
|
|
83
|
+
|
|
84
|
+
@property
|
|
85
|
+
def review_routing(self) -> list[RoutingEntry]:
|
|
86
|
+
"""Routing entries the review emitted (frozen at bookend)."""
|
|
87
|
+
return list(self.artifacts.get("review_routing", []))
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
# Callable type aliases for Phase fields.
|
|
91
|
+
BuildEnv = Callable[[Context], dict[str, str]]
|
|
92
|
+
SuccessPredicate = Callable[[str], bool]
|
|
93
|
+
PostProcess = Callable[[str, Context], Optional[list["Phase"]]]
|
|
94
|
+
# SkipIf returns falsy to run, True for a generic skip, or a str carrying
|
|
95
|
+
# the reason. The reason lands in the session log.
|
|
96
|
+
SkipIf = Callable[[Context], "bool | str | None"]
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _empty_env(_ctx: Context) -> dict[str, str]:
|
|
100
|
+
return {}
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
@dataclass
|
|
104
|
+
class Phase:
|
|
105
|
+
"""A single recipe invocation, with optional pre/post hooks.
|
|
106
|
+
|
|
107
|
+
Attributes:
|
|
108
|
+
name: shown in banners and logs.
|
|
109
|
+
recipe_path: relative path to the recipe yaml.
|
|
110
|
+
build_env: returns env vars merged with base_env for this call.
|
|
111
|
+
success_predicate: optional override for "did this attempt succeed?".
|
|
112
|
+
None falls through to the looper's transient-error check.
|
|
113
|
+
post_process: called after a successful run. May return a list of
|
|
114
|
+
child Phases for the Looper to enqueue.
|
|
115
|
+
skip_if: called before the recipe runs. Truthy return skips the
|
|
116
|
+
Phase. A string return is used as the skip reason in the log.
|
|
117
|
+
label: optional override for the per-call footer label. Useful when
|
|
118
|
+
multiple phases reuse the same recipe (review-spawned branches).
|
|
119
|
+
"""
|
|
120
|
+
name: str
|
|
121
|
+
recipe_path: str
|
|
122
|
+
build_env: BuildEnv = _empty_env
|
|
123
|
+
success_predicate: Optional[SuccessPredicate] = None
|
|
124
|
+
post_process: Optional[PostProcess] = None
|
|
125
|
+
skip_if: Optional[SkipIf] = None
|
|
126
|
+
label: Optional[str] = None
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
@dataclass
|
|
130
|
+
class Pipeline:
|
|
131
|
+
"""Named-slot pipeline. Framework owns review-first / summary-last ordering.
|
|
132
|
+
|
|
133
|
+
Engines return this from Engine.pipeline(). Per ADR 0006:
|
|
134
|
+
|
|
135
|
+
- review runs first; its post_process and the framework parse the
|
|
136
|
+
review output and spawn child phases (via the BranchPolicy
|
|
137
|
+
registry) that go to the HEAD of the body queue.
|
|
138
|
+
- body runs after, in queue order. May be empty.
|
|
139
|
+
- summary runs last, with access to the final operator_actions
|
|
140
|
+
ledger and outputs list.
|
|
141
|
+
|
|
142
|
+
`--review-only` runs review and stops; body and summary skip.
|
|
143
|
+
"""
|
|
144
|
+
review: Phase
|
|
145
|
+
body: list[Phase] = field(default_factory=list)
|
|
146
|
+
summary: Optional[Phase] = None
|
gooseloop/predicates.py
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""Reusable success-predicate factories.
|
|
2
|
+
|
|
3
|
+
A Phase's success_predicate decides whether a recipe attempt succeeded.
|
|
4
|
+
Three named shapes cover the common cases:
|
|
5
|
+
|
|
6
|
+
file_freshly_touched(path, pre_mtime) - strict: exists, non-empty, newer than pre_mtime
|
|
7
|
+
file_nonempty(path) - loose: exists and non-empty
|
|
8
|
+
json_in_stdout(required_keys) - sentinel JSON parses and has keys
|
|
9
|
+
|
|
10
|
+
Phase(success_predicate=None) falls through to the looper's transient-error
|
|
11
|
+
check; that is the explicit "no extra validation" option. No no-op
|
|
12
|
+
predicate factory is shipped (per YAGNI; six characters of lambda if a
|
|
13
|
+
recipe ever genuinely needs one).
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Callable, Iterable
|
|
18
|
+
|
|
19
|
+
from .extract import extract_json
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
SuccessPredicate = Callable[[str], bool]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def file_freshly_touched(path: Path, pre_mtime: float = 0.0) -> SuccessPredicate:
|
|
26
|
+
"""Strict: `path` exists, is non-empty, AND its mtime is strictly newer
|
|
27
|
+
than `pre_mtime`.
|
|
28
|
+
|
|
29
|
+
Snapshot pre_mtime BEFORE the recipe runs; if the file didn't exist,
|
|
30
|
+
pass 0.0. Use when the recipe writes to a deterministic path and you
|
|
31
|
+
need to detect both "model didn't write" and "model wrote a different
|
|
32
|
+
file so this one is stale."
|
|
33
|
+
"""
|
|
34
|
+
def predicate(_output: str) -> bool:
|
|
35
|
+
if not path.exists():
|
|
36
|
+
return False
|
|
37
|
+
if path.stat().st_size == 0:
|
|
38
|
+
return False
|
|
39
|
+
return path.stat().st_mtime > pre_mtime
|
|
40
|
+
return predicate
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def file_nonempty(path: Path) -> SuccessPredicate:
|
|
44
|
+
"""Loose: `path` exists and is non-empty.
|
|
45
|
+
|
|
46
|
+
Safe only when you already know the file didn't exist before the
|
|
47
|
+
recipe ran (otherwise a pre-existing file passes without proof the
|
|
48
|
+
recipe wrote anything).
|
|
49
|
+
"""
|
|
50
|
+
def predicate(_output: str) -> bool:
|
|
51
|
+
return path.exists() and path.stat().st_size > 0
|
|
52
|
+
return predicate
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def json_in_stdout(required_keys: Iterable[str] | None = None) -> SuccessPredicate:
|
|
56
|
+
"""The recipe's stdout contains sentinel-wrapped JSON with all required_keys.
|
|
57
|
+
|
|
58
|
+
Use for stdout-deliverable recipes (the JSON IS the artifact). Catches
|
|
59
|
+
the "model emitted a thin stub instead of the full schema" failure
|
|
60
|
+
mode.
|
|
61
|
+
"""
|
|
62
|
+
keys = list(required_keys or [])
|
|
63
|
+
|
|
64
|
+
def predicate(output: str) -> bool:
|
|
65
|
+
data = extract_json(output)
|
|
66
|
+
if data is None:
|
|
67
|
+
return False
|
|
68
|
+
return all(k in data for k in keys)
|
|
69
|
+
return predicate
|
gooseloop/protocol.py
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
"""TypedDicts and validation for the gooseloop review protocol.
|
|
2
|
+
|
|
3
|
+
See PROTOCOL.md for the canonical contract and ADR 0007 for
|
|
4
|
+
the rationale. The framework reads these keys; engines may add arbitrary
|
|
5
|
+
extension keys to any of these structures.
|
|
6
|
+
|
|
7
|
+
Validation is **liberal in what it accepts, strict in what the schema
|
|
8
|
+
guarantees on the way out**:
|
|
9
|
+
|
|
10
|
+
- Status synonyms (success/ok/complete/failed/incomplete/...) canonicalise
|
|
11
|
+
to the three-value enum. Unknown synonyms raise.
|
|
12
|
+
- Non-load-bearing keys (insights, operator_actions) default to [] when
|
|
13
|
+
missing. They're informational; absence is "model had nothing to say"
|
|
14
|
+
not "model broke the contract."
|
|
15
|
+
- Load-bearing keys (protocol_version, status, summary, routing) are
|
|
16
|
+
strictly required. Absence means the review can't drive the framework.
|
|
17
|
+
- protocol_version major must match what we ship.
|
|
18
|
+
|
|
19
|
+
This is the validate-side companion to the parser-side leniency in
|
|
20
|
+
gooseloop.extract: accept what's obviously the same intent in a slightly
|
|
21
|
+
different shape, fail loud on actual contract violations.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from typing import Any, Literal, TypedDict
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
PROTOCOL_VERSION = "1.0"
|
|
28
|
+
PROTOCOL_MAJOR = 1
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class RoutingEntry(TypedDict, total=False):
|
|
32
|
+
"""One entry in the review's routing[] list."""
|
|
33
|
+
recipe: str
|
|
34
|
+
params: dict[str, Any]
|
|
35
|
+
reason: str
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class OperatorAction(TypedDict, total=False):
|
|
39
|
+
"""One entry in the session's operator_actions ledger."""
|
|
40
|
+
action: str
|
|
41
|
+
why: str
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
ReviewStatus = Literal["done", "partial", "error"]
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class ReviewOutput(TypedDict, total=False):
|
|
48
|
+
"""Canonical review payload after validation."""
|
|
49
|
+
protocol_version: str
|
|
50
|
+
status: ReviewStatus
|
|
51
|
+
summary: str
|
|
52
|
+
insights: list[str]
|
|
53
|
+
routing: list[RoutingEntry]
|
|
54
|
+
operator_actions: list[OperatorAction]
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
# Keys the framework can't function without. A review missing any of
|
|
58
|
+
# these can't drive routing or the session ledger; refuse loud.
|
|
59
|
+
REQUIRED_KEYS = ("protocol_version", "status", "summary", "routing")
|
|
60
|
+
|
|
61
|
+
# Keys the framework reads but treats as optional in the schema. Missing
|
|
62
|
+
# = "model had nothing to add"; default to empty list and proceed.
|
|
63
|
+
DEFAULTED_LIST_KEYS = ("insights", "operator_actions")
|
|
64
|
+
|
|
65
|
+
# Status synonyms. Models drift on the status enum more than on any
|
|
66
|
+
# other field; canonicalise here so downstream code only sees the three
|
|
67
|
+
# canonical values. Unknown values raise (a fresh synonym should land
|
|
68
|
+
# in this table, not slip past the validator).
|
|
69
|
+
_STATUS_SYNONYMS: dict[str, ReviewStatus] = {
|
|
70
|
+
"done": "done",
|
|
71
|
+
"success": "done",
|
|
72
|
+
"ok": "done",
|
|
73
|
+
"complete": "done",
|
|
74
|
+
"completed": "done",
|
|
75
|
+
"finished": "done",
|
|
76
|
+
"partial": "partial",
|
|
77
|
+
"incomplete": "partial",
|
|
78
|
+
"in_progress": "partial",
|
|
79
|
+
"in-progress": "partial",
|
|
80
|
+
"pending": "partial",
|
|
81
|
+
"error": "error",
|
|
82
|
+
"errored": "error",
|
|
83
|
+
"failed": "error",
|
|
84
|
+
"failure": "error",
|
|
85
|
+
"broken": "error",
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class ProtocolVersionError(RuntimeError):
|
|
90
|
+
"""Raised when a review declares a major version the framework does not support."""
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def validate_review(payload: dict) -> ReviewOutput:
|
|
94
|
+
"""Canonicalise + validate a review payload.
|
|
95
|
+
|
|
96
|
+
Returns a NEW dict with synonyms canonicalised, defaulted lists
|
|
97
|
+
filled in, and entry shapes normalised. The original payload is
|
|
98
|
+
not mutated.
|
|
99
|
+
|
|
100
|
+
Normalisations performed (liberal in what we accept):
|
|
101
|
+
|
|
102
|
+
- status: synonyms ("success", "ok", ...) collapse to the
|
|
103
|
+
three-value enum.
|
|
104
|
+
- insights/operator_actions: missing or None becomes [].
|
|
105
|
+
- operator_actions entries: a bare string becomes
|
|
106
|
+
{"action": <string>, "why": ""}. A dict missing "why" gets
|
|
107
|
+
why="". Malformed entries (non-string, non-dict, or dict with
|
|
108
|
+
no "action") are dropped.
|
|
109
|
+
- routing entries: a dict missing optional fields gets default
|
|
110
|
+
params={} and reason="". Entries without a "recipe" string
|
|
111
|
+
are dropped.
|
|
112
|
+
"""
|
|
113
|
+
missing = [k for k in REQUIRED_KEYS if k not in payload]
|
|
114
|
+
if missing:
|
|
115
|
+
raise ValueError(f"review missing required keys: {missing}")
|
|
116
|
+
|
|
117
|
+
_check_protocol_version(str(payload["protocol_version"]))
|
|
118
|
+
|
|
119
|
+
raw_status = str(payload["status"]).strip().lower()
|
|
120
|
+
canonical_status = _STATUS_SYNONYMS.get(raw_status)
|
|
121
|
+
if canonical_status is None:
|
|
122
|
+
raise ValueError(
|
|
123
|
+
f"review status {payload['status']!r} not recognised "
|
|
124
|
+
f"(known: {sorted(set(_STATUS_SYNONYMS))})"
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
out: dict[str, Any] = dict(payload)
|
|
128
|
+
out["status"] = canonical_status
|
|
129
|
+
for key in DEFAULTED_LIST_KEYS:
|
|
130
|
+
if key not in out or out[key] is None:
|
|
131
|
+
out[key] = []
|
|
132
|
+
out["operator_actions"] = _normalise_operator_actions(out["operator_actions"])
|
|
133
|
+
out["routing"] = _normalise_routing(out["routing"])
|
|
134
|
+
return out # type: ignore[return-value]
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _normalise_operator_actions(entries: Any) -> list[OperatorAction]:
|
|
138
|
+
"""Coerce loose shapes into well-formed OperatorAction dicts."""
|
|
139
|
+
if not isinstance(entries, list):
|
|
140
|
+
return []
|
|
141
|
+
out: list[OperatorAction] = []
|
|
142
|
+
for entry in entries:
|
|
143
|
+
if isinstance(entry, str):
|
|
144
|
+
action = entry.strip()
|
|
145
|
+
if action:
|
|
146
|
+
out.append({"action": action, "why": ""}) # type: ignore[typeddict-item]
|
|
147
|
+
continue
|
|
148
|
+
if isinstance(entry, dict):
|
|
149
|
+
action = str(entry.get("action", "")).strip()
|
|
150
|
+
if not action:
|
|
151
|
+
continue
|
|
152
|
+
normalised: OperatorAction = { # type: ignore[typeddict-item]
|
|
153
|
+
"action": action,
|
|
154
|
+
"why": str(entry.get("why", "")),
|
|
155
|
+
}
|
|
156
|
+
for k, v in entry.items():
|
|
157
|
+
if k not in ("action", "why"):
|
|
158
|
+
normalised[k] = v # type: ignore[literal-required]
|
|
159
|
+
out.append(normalised)
|
|
160
|
+
return out
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _normalise_routing(entries: Any) -> list[RoutingEntry]:
|
|
164
|
+
"""Coerce loose shapes into well-formed RoutingEntry dicts."""
|
|
165
|
+
if not isinstance(entries, list):
|
|
166
|
+
return []
|
|
167
|
+
out: list[RoutingEntry] = []
|
|
168
|
+
for entry in entries:
|
|
169
|
+
if not isinstance(entry, dict):
|
|
170
|
+
continue
|
|
171
|
+
recipe = entry.get("recipe")
|
|
172
|
+
if not isinstance(recipe, str) or not recipe.strip():
|
|
173
|
+
continue
|
|
174
|
+
normalised: RoutingEntry = { # type: ignore[typeddict-item]
|
|
175
|
+
"recipe": recipe.strip(),
|
|
176
|
+
"params": entry.get("params") if isinstance(entry.get("params"), dict) else {},
|
|
177
|
+
"reason": str(entry.get("reason", "")),
|
|
178
|
+
}
|
|
179
|
+
out.append(normalised)
|
|
180
|
+
return out
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _check_protocol_version(declared: str) -> None:
|
|
184
|
+
try:
|
|
185
|
+
major = int(declared.split(".", 1)[0])
|
|
186
|
+
except (ValueError, IndexError):
|
|
187
|
+
raise ProtocolVersionError(
|
|
188
|
+
f"protocol_version {declared!r} is not parseable; "
|
|
189
|
+
f"framework supports major {PROTOCOL_MAJOR}"
|
|
190
|
+
)
|
|
191
|
+
if major != PROTOCOL_MAJOR:
|
|
192
|
+
raise ProtocolVersionError(
|
|
193
|
+
f"review declares protocol_version {declared!r} (major {major}); "
|
|
194
|
+
f"framework supports major {PROTOCOL_MAJOR} only"
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
"""Compose-style recipe overlay merge.
|
|
2
|
+
|
|
3
|
+
Per ADR 0008 and PROTOCOL.md §6. Two YAML documents merge according to a
|
|
4
|
+
type-and-name-dispatched rule table:
|
|
5
|
+
|
|
6
|
+
scalars - later wins (full replace)
|
|
7
|
+
dicts - deep-merge; scalar leaves: later wins
|
|
8
|
+
context list - keyed by 'label'; same label overrides, new appends
|
|
9
|
+
extensions list - keyed by (type, name); same key overrides, new appends
|
|
10
|
+
plain lists - later replaces fully
|
|
11
|
+
|
|
12
|
+
A keyed-list overlay entry with `source: REMOVE` (the removal sentinel)
|
|
13
|
+
deletes that entry from the merge result.
|
|
14
|
+
|
|
15
|
+
Layer order is `merge_recipes(base, *overlays)`; later overlays override
|
|
16
|
+
earlier ones. The looper composes layers in the order: base file ->
|
|
17
|
+
<name>.local.yaml -> --review-overlay X1 -> --review-overlay X2 -> ...
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import Any
|
|
22
|
+
|
|
23
|
+
import yaml
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
REMOVE_SENTINEL = "REMOVE"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def merge_recipes(base: dict, *overlays: dict) -> dict:
|
|
30
|
+
"""Merge `base` with successive overlays, returning a new dict.
|
|
31
|
+
|
|
32
|
+
The merge is pure: input dicts are never mutated. Per ADR 0008 the
|
|
33
|
+
merge rules dispatch on field name (for keyed lists) and value type
|
|
34
|
+
(everything else).
|
|
35
|
+
"""
|
|
36
|
+
out = _copy(base)
|
|
37
|
+
for overlay in overlays:
|
|
38
|
+
out = _merge_dict(out, overlay, path=())
|
|
39
|
+
return out
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def load_layered_recipe(
|
|
43
|
+
base_path: Path,
|
|
44
|
+
*,
|
|
45
|
+
local_path: Path | None = None,
|
|
46
|
+
overlay_paths: list[Path] | None = None,
|
|
47
|
+
) -> dict:
|
|
48
|
+
"""Resolve a recipe by loading base + local + CLI overlays.
|
|
49
|
+
|
|
50
|
+
Convention: `<name>.local.yaml` sits next to the base recipe in the
|
|
51
|
+
same directory; the caller computes the local path and passes it.
|
|
52
|
+
Missing local file is fine (skipped).
|
|
53
|
+
"""
|
|
54
|
+
base = _read_yaml(base_path)
|
|
55
|
+
overlays: list[dict] = []
|
|
56
|
+
if local_path is not None and local_path.exists():
|
|
57
|
+
overlays.append(_read_yaml(local_path))
|
|
58
|
+
for p in overlay_paths or []:
|
|
59
|
+
overlays.append(_read_yaml(p))
|
|
60
|
+
return merge_recipes(base, *overlays)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def resolved_recipe_yaml(merged: dict) -> str:
|
|
64
|
+
"""Render the merged recipe back to YAML (for --resolve debug output)."""
|
|
65
|
+
return yaml.safe_dump(merged, sort_keys=False, default_flow_style=False)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
# ---- internals ------------------------------------------------------
|
|
69
|
+
|
|
70
|
+
# Keyed-list rules: list-field name -> the key function on each entry.
|
|
71
|
+
_KEYED_LISTS: dict[str, Any] = {
|
|
72
|
+
"context": lambda entry: entry.get("label"),
|
|
73
|
+
"extensions": lambda entry: (entry.get("type"), entry.get("name")),
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _read_yaml(path: Path) -> dict:
|
|
78
|
+
text = path.read_text()
|
|
79
|
+
data = yaml.safe_load(text)
|
|
80
|
+
if data is None:
|
|
81
|
+
return {}
|
|
82
|
+
if not isinstance(data, dict):
|
|
83
|
+
raise ValueError(f"recipe at {path} must be a YAML mapping at top level")
|
|
84
|
+
return data
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _copy(value: Any) -> Any:
|
|
88
|
+
if isinstance(value, dict):
|
|
89
|
+
return {k: _copy(v) for k, v in value.items()}
|
|
90
|
+
if isinstance(value, list):
|
|
91
|
+
return [_copy(v) for v in value]
|
|
92
|
+
return value
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _merge_dict(base: dict, overlay: dict, *, path: tuple[str, ...]) -> dict:
|
|
96
|
+
out = _copy(base)
|
|
97
|
+
for k, ov in overlay.items():
|
|
98
|
+
if k not in out:
|
|
99
|
+
out[k] = _copy(ov)
|
|
100
|
+
continue
|
|
101
|
+
bv = out[k]
|
|
102
|
+
if isinstance(bv, dict) and isinstance(ov, dict):
|
|
103
|
+
out[k] = _merge_dict(bv, ov, path=path + (k,))
|
|
104
|
+
elif isinstance(bv, list) and isinstance(ov, list):
|
|
105
|
+
out[k] = _merge_list(bv, ov, list_name=k)
|
|
106
|
+
else:
|
|
107
|
+
out[k] = _copy(ov)
|
|
108
|
+
return out
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _merge_list(base: list, overlay: list, *, list_name: str) -> list:
|
|
112
|
+
keyer = _KEYED_LISTS.get(list_name)
|
|
113
|
+
if keyer is None:
|
|
114
|
+
# Plain list: later replaces fully.
|
|
115
|
+
return _copy(overlay)
|
|
116
|
+
return _merge_keyed_list(base, overlay, keyer=keyer)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _merge_keyed_list(base: list, overlay: list, *, keyer) -> list:
|
|
120
|
+
"""Merge two keyed lists by item identity.
|
|
121
|
+
|
|
122
|
+
Walk overlay in order. For each entry:
|
|
123
|
+
- If key matches an existing base entry: merge fields (overlay wins).
|
|
124
|
+
REMOVE sentinel on `source` field deletes the base entry.
|
|
125
|
+
- If key is new: append to the result.
|
|
126
|
+
|
|
127
|
+
Preserves base order for retained entries; new entries land at the
|
|
128
|
+
end in overlay order.
|
|
129
|
+
"""
|
|
130
|
+
result: list = [_copy(b) for b in base]
|
|
131
|
+
by_key: dict[Any, int] = {}
|
|
132
|
+
for i, entry in enumerate(result):
|
|
133
|
+
if isinstance(entry, dict):
|
|
134
|
+
by_key[keyer(entry)] = i
|
|
135
|
+
|
|
136
|
+
for entry in overlay:
|
|
137
|
+
if not isinstance(entry, dict):
|
|
138
|
+
result.append(_copy(entry))
|
|
139
|
+
continue
|
|
140
|
+
key = keyer(entry)
|
|
141
|
+
if key in by_key:
|
|
142
|
+
if entry.get("source") == REMOVE_SENTINEL:
|
|
143
|
+
idx = by_key.pop(key)
|
|
144
|
+
result[idx] = None # tombstone
|
|
145
|
+
continue
|
|
146
|
+
idx = by_key[key]
|
|
147
|
+
merged = _merge_dict(result[idx], entry, path=())
|
|
148
|
+
result[idx] = merged
|
|
149
|
+
else:
|
|
150
|
+
result.append(_copy(entry))
|
|
151
|
+
|
|
152
|
+
return [r for r in result if r is not None]
|
gooseloop/session.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Session folder management: create timestamped dirs, log steps.
|
|
2
|
+
|
|
3
|
+
The Looper owns the session layout:
|
|
4
|
+
|
|
5
|
+
<sessions_dir>/<UTC-timestamp>/
|
|
6
|
+
session.meta.json - model, engine, timestamps
|
|
7
|
+
session.log - append-only event log
|
|
8
|
+
actions/ - Phase outputs land here
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
from datetime import datetime, timezone
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
from .text import Color, banner
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def new_session(sessions_dir: Path, model: str, engine_name: str) -> Path:
|
|
19
|
+
"""Create a timestamped session directory and print a banner."""
|
|
20
|
+
ts = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H-%M-%S")
|
|
21
|
+
session_dir = sessions_dir / ts
|
|
22
|
+
session_dir.mkdir(parents=True, exist_ok=True)
|
|
23
|
+
(session_dir / "actions").mkdir(exist_ok=True)
|
|
24
|
+
|
|
25
|
+
meta = {
|
|
26
|
+
"session_started": datetime.now(timezone.utc).isoformat(),
|
|
27
|
+
"model": model,
|
|
28
|
+
"engine": engine_name,
|
|
29
|
+
}
|
|
30
|
+
with open(session_dir / "session.meta.json", "w") as f:
|
|
31
|
+
json.dump(meta, f, indent=2)
|
|
32
|
+
|
|
33
|
+
banner(f"Session started: {session_dir}", Color.GREEN)
|
|
34
|
+
return session_dir
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def log_step(session_dir: Path, message: str) -> None:
|
|
38
|
+
"""Append a timestamped line to session.log."""
|
|
39
|
+
ts = datetime.now(timezone.utc).isoformat()
|
|
40
|
+
with open(session_dir / "session.log", "a") as f:
|
|
41
|
+
f.write(f"[{ts}] {message}\n")
|
gooseloop/text.py
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""Terminal text helpers: ANSI colours, banners, ANSI stripping.
|
|
2
|
+
|
|
3
|
+
JSON extraction used to live here; it moved to gooseloop/extract.py once
|
|
4
|
+
it grew a recognizer dispatch table. text.py is now scoped strictly to
|
|
5
|
+
terminal-output utilities.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import re
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Color:
|
|
12
|
+
RESET = '\033[0m'
|
|
13
|
+
BOLD = '\033[1m'
|
|
14
|
+
RED = '\033[91m'
|
|
15
|
+
GREEN = '\033[92m'
|
|
16
|
+
YELLOW = '\033[93m'
|
|
17
|
+
BLUE = '\033[94m'
|
|
18
|
+
MAGENTA = '\033[95m'
|
|
19
|
+
CYAN = '\033[96m'
|
|
20
|
+
WHITE = '\033[97m'
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def colored(text: str, color: str) -> str:
|
|
24
|
+
return f"{color}{text}{Color.RESET}"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
_ANSI_RE = re.compile(r'\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])')
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def strip_ansi(text: str) -> str:
|
|
31
|
+
return _ANSI_RE.sub('', text)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def banner(text: str, color: str = Color.CYAN, width: int = 70) -> None:
|
|
35
|
+
"""Print a colourful double-line box around the given text."""
|
|
36
|
+
top = "╔" + "═" * (width - 2) + "╗"
|
|
37
|
+
bot = "╚" + "═" * (width - 2) + "╝"
|
|
38
|
+
|
|
39
|
+
max_text_width = width - 4
|
|
40
|
+
words = text.split()
|
|
41
|
+
lines: list[str] = []
|
|
42
|
+
current = ""
|
|
43
|
+
for w in words:
|
|
44
|
+
if len(current) + len(w) + 1 > max_text_width:
|
|
45
|
+
lines.append(current.strip())
|
|
46
|
+
current = w
|
|
47
|
+
else:
|
|
48
|
+
current = (current + " " + w).strip() if current else w
|
|
49
|
+
if current:
|
|
50
|
+
lines.append(current.strip())
|
|
51
|
+
if not lines:
|
|
52
|
+
lines = [""]
|
|
53
|
+
|
|
54
|
+
print(colored(top, color))
|
|
55
|
+
for line in lines:
|
|
56
|
+
padding = max_text_width - len(line)
|
|
57
|
+
left = padding // 2
|
|
58
|
+
right = padding - left
|
|
59
|
+
print(colored("║ " + " " * left + line + " " * right + " ║", color))
|
|
60
|
+
print(colored(bot, color))
|