foundry-implementation-actor 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.
- foundry_implementation_actor/__init__.py +38 -0
- foundry_implementation_actor/cli.py +107 -0
- foundry_implementation_actor/config.py +405 -0
- foundry_implementation_actor/correlation.py +193 -0
- foundry_implementation_actor/engine.py +434 -0
- foundry_implementation_actor/grounding.py +197 -0
- foundry_implementation_actor/handler.py +220 -0
- foundry_implementation_actor/schemas/agentic-context.schema.yaml +143 -0
- foundry_implementation_actor-0.1.0.dist-info/METADATA +217 -0
- foundry_implementation_actor-0.1.0.dist-info/RECORD +12 -0
- foundry_implementation_actor-0.1.0.dist-info/WHEEL +4 -0
- foundry_implementation_actor-0.1.0.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""foundry-implementation-actor — run a headless Claude Code implementation session against one
|
|
2
|
+
capability's own repo.
|
|
3
|
+
|
|
4
|
+
An actor, for one use, with a `papeete-actor` underneath. The capability it serves is supplied by
|
|
5
|
+
a sidecar (`actor-agentic-context.yaml`, `foundry-implementation-actor/agentic-context/v1`), never
|
|
6
|
+
by this package: a second capability instantiates the same actor by writing that file and nothing
|
|
7
|
+
else.
|
|
8
|
+
|
|
9
|
+
Wiring one up is four lines:
|
|
10
|
+
|
|
11
|
+
from foundry_implementation_actor import CapabilityConfig, ClaudeCodeEngine, make_implement_task
|
|
12
|
+
|
|
13
|
+
config = CapabilityConfig.load(".")
|
|
14
|
+
actor = Actor.from_card(".", mailbox=mailbox,
|
|
15
|
+
engines={config.engine: ClaudeCodeEngine(config)},
|
|
16
|
+
actions={"implement-task": make_implement_task(config)})
|
|
17
|
+
|
|
18
|
+
`correlation` is exported too — an entrypoint installs its filter on the root logger's handlers
|
|
19
|
+
after configuring observability, so every record the process emits carries this request's ids.
|
|
20
|
+
"""
|
|
21
|
+
from .config import CapabilityConfig, Component, ConfigError, Grounding, Report, lint
|
|
22
|
+
from .engine import ClaudeCodeEngine
|
|
23
|
+
from .handler import HandlerError, make_implement_task
|
|
24
|
+
from . import correlation, grounding
|
|
25
|
+
|
|
26
|
+
__all__ = [
|
|
27
|
+
"CapabilityConfig",
|
|
28
|
+
"ClaudeCodeEngine",
|
|
29
|
+
"Component",
|
|
30
|
+
"ConfigError",
|
|
31
|
+
"Grounding",
|
|
32
|
+
"HandlerError",
|
|
33
|
+
"Report",
|
|
34
|
+
"correlation",
|
|
35
|
+
"grounding",
|
|
36
|
+
"lint",
|
|
37
|
+
"make_implement_task",
|
|
38
|
+
]
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""`foundry-implementation-actor` — the gate, and the derivation table.
|
|
2
|
+
|
|
3
|
+
Two subcommands, both thin. `lint` is what CI runs against a sidecar; `show` prints every
|
|
4
|
+
rendering `config.py` derives from the two fields that are actually written down, so an operator
|
|
5
|
+
can check the image ref an actor WILL publish before it publishes one — the previous arrangement
|
|
6
|
+
could only be checked by reading a running actor's logs after the fact.
|
|
7
|
+
|
|
8
|
+
House rule: every published package in this ecosystem ships a CLI named exactly the package.
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import argparse
|
|
13
|
+
import sys
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
from .config import CapabilityConfig, ConfigError, lint
|
|
17
|
+
|
|
18
|
+
_REGISTRY_PLACEHOLDER = "<registry>"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _cmd_lint(args: argparse.Namespace) -> int:
|
|
22
|
+
report = lint(Path(args.folder))
|
|
23
|
+
for warning in report.warns:
|
|
24
|
+
print(f" ! {warning}")
|
|
25
|
+
for error in report.errors:
|
|
26
|
+
print(f" FAIL {error}")
|
|
27
|
+
if not report.ok:
|
|
28
|
+
print(f"✗ {len(report.errors)} error(s)")
|
|
29
|
+
return 1
|
|
30
|
+
for line in report.oks:
|
|
31
|
+
print(f" ok {line}")
|
|
32
|
+
print("✓ sidecar conforms")
|
|
33
|
+
return 0
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _cmd_show(args: argparse.Namespace) -> int:
|
|
37
|
+
try:
|
|
38
|
+
config = CapabilityConfig.load(Path(args.folder))
|
|
39
|
+
except ConfigError as e:
|
|
40
|
+
print(f" FAIL {e}")
|
|
41
|
+
return 2
|
|
42
|
+
registry = args.registry or _REGISTRY_PLACEHOLDER
|
|
43
|
+
|
|
44
|
+
rows = [
|
|
45
|
+
("capability", config.capability),
|
|
46
|
+
("source_repo", config.source_repo),
|
|
47
|
+
("registry_repo", config.registry_repo),
|
|
48
|
+
("engine", config.engine),
|
|
49
|
+
("actor name / git author", config.git_author_name),
|
|
50
|
+
("git author email", config.git_author_email),
|
|
51
|
+
("clone prefix", config.clone_prefix("TASK-NNN")),
|
|
52
|
+
("registry path", config.capability_path),
|
|
53
|
+
("writes only under", ", ".join(config.writes_only_under)),
|
|
54
|
+
]
|
|
55
|
+
width = max(len(label) for label, _ in rows)
|
|
56
|
+
for label, value in rows:
|
|
57
|
+
print(f" {label:<{width}} {value}")
|
|
58
|
+
|
|
59
|
+
print("\n components")
|
|
60
|
+
for component in config.components:
|
|
61
|
+
print(f" {component.name}")
|
|
62
|
+
print(f" path {component.path}")
|
|
63
|
+
print(f" tests {component.tests}")
|
|
64
|
+
print(f" dockerfile {component.dockerfile}")
|
|
65
|
+
print(f" image name {config.image_name(component.name)}")
|
|
66
|
+
print(f" image ref "
|
|
67
|
+
f"{config.image_ref(registry, component.name, '<version>')}")
|
|
68
|
+
|
|
69
|
+
print("\n ground_in")
|
|
70
|
+
for entry in config.ground_in:
|
|
71
|
+
print(f" {entry.name} [{entry.load}] → {entry.into}")
|
|
72
|
+
print(f" answers {entry.answers}")
|
|
73
|
+
print(f" fetch {' '.join(config.expand(entry.fetch))}")
|
|
74
|
+
return 0
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
78
|
+
parser = argparse.ArgumentParser(
|
|
79
|
+
prog="foundry-implementation-actor",
|
|
80
|
+
description="Inspect and validate one capability's agentic-context sidecar.",
|
|
81
|
+
)
|
|
82
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
83
|
+
|
|
84
|
+
lint_parser = sub.add_parser(
|
|
85
|
+
"lint", help="validate a sidecar against foundry-implementation-actor/agentic-context/v1")
|
|
86
|
+
lint_parser.add_argument(
|
|
87
|
+
"folder", nargs="?", default=".",
|
|
88
|
+
help="the actor's folder, or the sidecar file itself (default: .)")
|
|
89
|
+
lint_parser.set_defaults(func=_cmd_lint)
|
|
90
|
+
|
|
91
|
+
show_parser = sub.add_parser(
|
|
92
|
+
"show", help="print every identifier derived from the sidecar's capability and repo")
|
|
93
|
+
show_parser.add_argument("folder", nargs="?", default=".")
|
|
94
|
+
show_parser.add_argument(
|
|
95
|
+
"--registry", help=f"render image refs against this registry (default: {_REGISTRY_PLACEHOLDER})")
|
|
96
|
+
show_parser.set_defaults(func=_cmd_show)
|
|
97
|
+
|
|
98
|
+
return parser
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def main(argv: list[str] | None = None) -> int:
|
|
102
|
+
args = build_parser().parse_args(argv)
|
|
103
|
+
return args.func(args)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
if __name__ == "__main__":
|
|
107
|
+
sys.exit(main())
|
|
@@ -0,0 +1,405 @@
|
|
|
1
|
+
"""`CapabilityConfig` — one capability id and one repo, every other rendering derived.
|
|
2
|
+
|
|
3
|
+
WHY THIS FILE EXISTS. Before it, the capability id was hand-written **eight** different ways
|
|
4
|
+
across two modules: the dotted id, the `<owner>/<repo>`, the registry path with `CAP` dropped, the
|
|
5
|
+
image name, the tempdir prefix, the git `user.name`, the git `user.email`, and the full image ref.
|
|
6
|
+
Eight literals for one fact, each correct only for as long as someone remembered to change all
|
|
7
|
+
eight together — and two of them (the repo, spelled once in the engine and once in the handler)
|
|
8
|
+
had already been copied rather than shared.
|
|
9
|
+
|
|
10
|
+
They are now derivations of two fields. Nothing in this package spells a capability.
|
|
11
|
+
|
|
12
|
+
capability ACME.PARTS.CAP.SUP.007.WID ← the only id anyone writes
|
|
13
|
+
source_repo <owner>/ACME.PARTS.CAP.SUP.007.WID-impl ← and the only repo
|
|
14
|
+
|
|
15
|
+
actor_name <repo half of source_repo>
|
|
16
|
+
actor_slug same, lowercased, dots to hyphens
|
|
17
|
+
git_author_name actor_name
|
|
18
|
+
git_author_email {actor_slug}@users.noreply.github.com
|
|
19
|
+
clone_prefix(t) {actor_slug}-{t}-
|
|
20
|
+
capability_path the id lowercased, split AT its `cap` segment: head / tail
|
|
21
|
+
image_name(c) {capability lowercased}-{c}
|
|
22
|
+
image_ref(r, c, v) {r}/{capability_path}/{c}:{v}
|
|
23
|
+
|
|
24
|
+
THE IMAGE REF IS A THREE-WAY CONTRACT. The testing actor recomputes the identical string and the
|
|
25
|
+
orchestration actor parses it back apart. `image_ref` must therefore stay byte-identical to what
|
|
26
|
+
those two agree on; it is derivation output or nothing, and no tag scheme is invented here.
|
|
27
|
+
`test_image_ref_is_the_three_way_contract` in the test suite is what pins it.
|
|
28
|
+
|
|
29
|
+
WHAT IS DERIVED AND WHAT IS DECLARED. Anything recoverable from the id or the repo is derived.
|
|
30
|
+
Anything genuinely additional — which components exist, where each may write, what grounds the
|
|
31
|
+
session — is declared in the sidecar, once. `writes_only_under` in particular is now
|
|
32
|
+
`components[].path` and nothing else: it used to be declared in the sidecar AND hardcoded in the
|
|
33
|
+
module that actually enforces it, which is how a write boundary comes to be stated twice and
|
|
34
|
+
eventually stated differently.
|
|
35
|
+
"""
|
|
36
|
+
from __future__ import annotations
|
|
37
|
+
|
|
38
|
+
import re
|
|
39
|
+
from dataclasses import dataclass
|
|
40
|
+
from pathlib import Path
|
|
41
|
+
|
|
42
|
+
import yaml
|
|
43
|
+
|
|
44
|
+
CONTRACT = "foundry-implementation-actor/agentic-context/v1"
|
|
45
|
+
SIDECAR = "actor-agentic-context.yaml"
|
|
46
|
+
|
|
47
|
+
_SCHEMA_PATH = Path(__file__).resolve().parent / "schemas" / "agentic-context.schema.yaml"
|
|
48
|
+
|
|
49
|
+
# The segment a capability id carries to say "capability". It is dropped from the registry path
|
|
50
|
+
# because the path position already says it — every other token of the id survives, across
|
|
51
|
+
# segments rather than concatenated.
|
|
52
|
+
_CAPABILITY_SEGMENT = "cap"
|
|
53
|
+
|
|
54
|
+
# What an unsubstituted placeholder looks like: a bare lowercase word in braces, and nothing else.
|
|
55
|
+
# Narrow on purpose — see `CapabilityConfig.expand`.
|
|
56
|
+
_PLACEHOLDER = re.compile(r"\{([a-z_]+)\}")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def load_schema() -> dict:
|
|
60
|
+
"""The contract, as committed source inside this package.
|
|
61
|
+
|
|
62
|
+
The path is the same in a source checkout and in an installed wheel, so there is no fallback
|
|
63
|
+
and no second location to reason about. A wheel that lost it is a gate with nothing to
|
|
64
|
+
enforce, which is worth failing loudly over rather than degrading past.
|
|
65
|
+
"""
|
|
66
|
+
if not _SCHEMA_PATH.exists():
|
|
67
|
+
raise FileNotFoundError(
|
|
68
|
+
f"{_SCHEMA_PATH.name} not found in {_SCHEMA_PATH.parent}.\n"
|
|
69
|
+
" The contract is committed source in this package, so this should be unreachable.\n"
|
|
70
|
+
" In a source checkout: the file was deleted — restore it from git.\n"
|
|
71
|
+
" In an installed wheel: the build shipped without its contract. Report it against "
|
|
72
|
+
"the release."
|
|
73
|
+
)
|
|
74
|
+
return yaml.safe_load(_SCHEMA_PATH.read_text())
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class ConfigError(ValueError):
|
|
78
|
+
"""The sidecar is unusable — missing, malformed, or internally inconsistent."""
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@dataclass(frozen=True)
|
|
82
|
+
class Component:
|
|
83
|
+
"""One unit this actor may write to and publish."""
|
|
84
|
+
|
|
85
|
+
name: str
|
|
86
|
+
path: str
|
|
87
|
+
tests: str
|
|
88
|
+
dockerfile: str
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@dataclass(frozen=True)
|
|
92
|
+
class Grounding:
|
|
93
|
+
"""One knowledge source the session is grounded in before its first turn."""
|
|
94
|
+
|
|
95
|
+
name: str
|
|
96
|
+
answers: str
|
|
97
|
+
fetch: tuple[str, ...]
|
|
98
|
+
into: str
|
|
99
|
+
load: str
|
|
100
|
+
|
|
101
|
+
@property
|
|
102
|
+
def eager(self) -> bool:
|
|
103
|
+
return self.load == "eager"
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@dataclass(frozen=True)
|
|
107
|
+
class CapabilityConfig:
|
|
108
|
+
"""Everything this actor needs to know about the capability it serves."""
|
|
109
|
+
|
|
110
|
+
capability: str
|
|
111
|
+
source_repo: str
|
|
112
|
+
registry_repo: str
|
|
113
|
+
engine: str
|
|
114
|
+
components: tuple[Component, ...]
|
|
115
|
+
ground_in: tuple[Grounding, ...]
|
|
116
|
+
|
|
117
|
+
# ── loading ─────────────────────────────────────────────────────────────────────────────
|
|
118
|
+
|
|
119
|
+
@classmethod
|
|
120
|
+
def load(cls, folder: str | Path = ".") -> CapabilityConfig:
|
|
121
|
+
"""Read the sidecar from `folder` (or from the file itself, if a file is given).
|
|
122
|
+
|
|
123
|
+
Raises `ConfigError` for anything that would otherwise surface much later — a missing
|
|
124
|
+
file, a contract this package does not implement, an absent required key, a component
|
|
125
|
+
whose `path` does not end in `/`. Every one of those is cheaper here than mid-session.
|
|
126
|
+
"""
|
|
127
|
+
path = Path(folder)
|
|
128
|
+
if path.is_dir():
|
|
129
|
+
path = path / SIDECAR
|
|
130
|
+
try:
|
|
131
|
+
raw = yaml.safe_load(path.read_text())
|
|
132
|
+
except OSError as e:
|
|
133
|
+
raise ConfigError(f"{path}: cannot be read: {e}") from e
|
|
134
|
+
except yaml.YAMLError as e:
|
|
135
|
+
raise ConfigError(f"{path}: does not parse: {e}") from e
|
|
136
|
+
return cls.from_dict(raw, source=str(path))
|
|
137
|
+
|
|
138
|
+
@classmethod
|
|
139
|
+
def from_dict(cls, raw: object, *, source: str = "<dict>") -> CapabilityConfig:
|
|
140
|
+
if not isinstance(raw, dict):
|
|
141
|
+
raise ConfigError(f"{source}: not a mapping")
|
|
142
|
+
if raw.get("context") != CONTRACT:
|
|
143
|
+
raise ConfigError(
|
|
144
|
+
f"{source}: declares context '{raw.get('context')}', not {CONTRACT}"
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
for key in load_schema()["required"]:
|
|
148
|
+
if key not in raw:
|
|
149
|
+
raise ConfigError(f"{source}: missing required key '{key}'")
|
|
150
|
+
|
|
151
|
+
components = tuple(_component(entry, source, i)
|
|
152
|
+
for i, entry in enumerate(raw["components"] or ()))
|
|
153
|
+
if not components:
|
|
154
|
+
raise ConfigError(f"{source}: `components` is empty — nothing this actor may write to")
|
|
155
|
+
|
|
156
|
+
ground_in = tuple(_grounding(entry, source, i)
|
|
157
|
+
for i, entry in enumerate(raw["ground_in"] or ()))
|
|
158
|
+
|
|
159
|
+
config = cls(
|
|
160
|
+
capability=str(raw["capability"]),
|
|
161
|
+
source_repo=str(raw["source_repo"]),
|
|
162
|
+
registry_repo=str(raw["registry_repo"]),
|
|
163
|
+
engine=str(raw["engine"]),
|
|
164
|
+
components=components,
|
|
165
|
+
ground_in=ground_in,
|
|
166
|
+
)
|
|
167
|
+
# Force the derivations that can fail, here rather than at the first request that needs
|
|
168
|
+
# one. A capability id with no `cap` segment is a typo, and it should not survive startup.
|
|
169
|
+
_ = config.capability_path, config.actor_name
|
|
170
|
+
return config
|
|
171
|
+
|
|
172
|
+
# ── the derived renderings ──────────────────────────────────────────────────────────────
|
|
173
|
+
|
|
174
|
+
@property
|
|
175
|
+
def actor_name(self) -> str:
|
|
176
|
+
"""The repo half of `source_repo` — this actor's own name, and its git author name."""
|
|
177
|
+
owner, _, repo = self.source_repo.partition("/")
|
|
178
|
+
if not owner or not repo:
|
|
179
|
+
raise ConfigError(
|
|
180
|
+
f"source_repo '{self.source_repo}' is not '<owner>/<repo>'"
|
|
181
|
+
)
|
|
182
|
+
return repo
|
|
183
|
+
|
|
184
|
+
@property
|
|
185
|
+
def actor_slug(self) -> str:
|
|
186
|
+
"""The actor name as a path/address-safe token: lowercased, dots to hyphens."""
|
|
187
|
+
return self.actor_name.lower().replace(".", "-")
|
|
188
|
+
|
|
189
|
+
@property
|
|
190
|
+
def git_author_name(self) -> str:
|
|
191
|
+
return self.actor_name
|
|
192
|
+
|
|
193
|
+
@property
|
|
194
|
+
def git_author_email(self) -> str:
|
|
195
|
+
return f"{self.actor_slug}@users.noreply.github.com"
|
|
196
|
+
|
|
197
|
+
def clone_prefix(self, task_id: str) -> str:
|
|
198
|
+
"""`tempfile.mkdtemp` prefix for one task's private clone."""
|
|
199
|
+
return f"{self.actor_slug}-{task_id}-"
|
|
200
|
+
|
|
201
|
+
@property
|
|
202
|
+
def capability_path(self) -> str:
|
|
203
|
+
"""The registry path form: the id lowercased and split AT its `cap` segment.
|
|
204
|
+
|
|
205
|
+
`<ENT>.<DOMAIN>.CAP.<TYPE>.<NNN>.<CODE>` becomes `<ent>.<domain>/<type>.<nnn>.<code>`.
|
|
206
|
+
`CAP` itself is dropped — the path position already says "capability". Nothing else is
|
|
207
|
+
shortened or abbreviated: every remaining token survives, across segments rather than
|
|
208
|
+
concatenated.
|
|
209
|
+
"""
|
|
210
|
+
segments = self.capability.lower().split(".")
|
|
211
|
+
if _CAPABILITY_SEGMENT not in segments:
|
|
212
|
+
raise ConfigError(
|
|
213
|
+
f"capability '{self.capability}' has no '{_CAPABILITY_SEGMENT.upper()}' segment — "
|
|
214
|
+
"the registry path is derived by splitting the id there, so an id without one "
|
|
215
|
+
"cannot be placed"
|
|
216
|
+
)
|
|
217
|
+
cut = segments.index(_CAPABILITY_SEGMENT)
|
|
218
|
+
head, tail = segments[:cut], segments[cut + 1:]
|
|
219
|
+
if not head or not tail:
|
|
220
|
+
raise ConfigError(
|
|
221
|
+
f"capability '{self.capability}': nothing on "
|
|
222
|
+
f"{'the left of' if not head else 'the right of'} its "
|
|
223
|
+
f"'{_CAPABILITY_SEGMENT.upper()}' segment"
|
|
224
|
+
)
|
|
225
|
+
return f"{'.'.join(head)}/{'.'.join(tail)}"
|
|
226
|
+
|
|
227
|
+
def image_name(self, component: str) -> str:
|
|
228
|
+
"""The name `papeete_version.compute` versions this component under."""
|
|
229
|
+
return f"{self.capability.lower()}-{component}"
|
|
230
|
+
|
|
231
|
+
def image_ref(self, registry: str, component: str, version: str) -> str:
|
|
232
|
+
"""The published ref. A three-way contract — see this module's own docstring."""
|
|
233
|
+
return f"{registry.rstrip('/')}/{self.capability_path}/{component}:{version}"
|
|
234
|
+
|
|
235
|
+
# ── components ──────────────────────────────────────────────────────────────────────────
|
|
236
|
+
|
|
237
|
+
@property
|
|
238
|
+
def writes_only_under(self) -> tuple[str, ...]:
|
|
239
|
+
"""The write boundary: the union of the components' own roots, and nothing else."""
|
|
240
|
+
return tuple(c.path for c in self.components)
|
|
241
|
+
|
|
242
|
+
@property
|
|
243
|
+
def test_paths(self) -> tuple[str, ...]:
|
|
244
|
+
return tuple(c.tests for c in self.components)
|
|
245
|
+
|
|
246
|
+
def component_for(self, path: str) -> Component | None:
|
|
247
|
+
"""The component a repo-relative path belongs to, by LONGEST matching prefix.
|
|
248
|
+
|
|
249
|
+
Not the path's first segment. That shortcut is correct only while every component root is
|
|
250
|
+
a single segment deep, and silently reports `src` for two different components the day one
|
|
251
|
+
of them is `src/gateway/`.
|
|
252
|
+
"""
|
|
253
|
+
matches = [c for c in self.components if path.startswith(c.path)]
|
|
254
|
+
return max(matches, key=lambda c: len(c.path)) if matches else None
|
|
255
|
+
|
|
256
|
+
def components_for(self, paths: list[str]) -> list[str]:
|
|
257
|
+
"""The names of the components a set of staged paths touched, sorted."""
|
|
258
|
+
names = set()
|
|
259
|
+
for path in paths:
|
|
260
|
+
component = self.component_for(path)
|
|
261
|
+
if component is not None:
|
|
262
|
+
names.add(component.name)
|
|
263
|
+
return sorted(names)
|
|
264
|
+
|
|
265
|
+
# ── grounding ───────────────────────────────────────────────────────────────────────────
|
|
266
|
+
|
|
267
|
+
def expand(self, argv: tuple[str, ...] | list[str]) -> list[str]:
|
|
268
|
+
"""Substitute this capability's own fields into a `ground_in` entry's `fetch:` argv.
|
|
269
|
+
|
|
270
|
+
LITERAL REPLACEMENT, NOT `str.format`. A fetch argv is somebody else's command line, and
|
|
271
|
+
braces are ordinary characters in one — a jq filter or a JSON literal passed as an
|
|
272
|
+
argument would raise or, worse, be silently mangled by `format`. Only the three names
|
|
273
|
+
below are substituted; every other brace passes through untouched.
|
|
274
|
+
|
|
275
|
+
A leftover `{bare_word}` IS still refused, because that is what a typo'd placeholder looks
|
|
276
|
+
like and passing it through would hand a knowledge tool a literal `{registryrepo}` to fail
|
|
277
|
+
on somewhere far from here. The pattern is deliberately narrow (lowercase and underscores
|
|
278
|
+
only) so a real brace-bearing argument does not trip it.
|
|
279
|
+
"""
|
|
280
|
+
values = {
|
|
281
|
+
"capability": self.capability,
|
|
282
|
+
"registry_repo": self.registry_repo,
|
|
283
|
+
"source_repo": self.source_repo,
|
|
284
|
+
}
|
|
285
|
+
out = []
|
|
286
|
+
for arg in argv:
|
|
287
|
+
rendered = arg
|
|
288
|
+
for key, value in values.items():
|
|
289
|
+
rendered = rendered.replace("{" + key + "}", value)
|
|
290
|
+
leftover = _PLACEHOLDER.search(rendered)
|
|
291
|
+
if leftover:
|
|
292
|
+
raise ConfigError(
|
|
293
|
+
f"fetch argument {arg!r} names a placeholder this config cannot supply "
|
|
294
|
+
f"({leftover.group(0)}); available: "
|
|
295
|
+
+ ", ".join("{" + k + "}" for k in sorted(values))
|
|
296
|
+
)
|
|
297
|
+
out.append(rendered)
|
|
298
|
+
return out
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def _component(entry: object, source: str, index: int) -> Component:
|
|
302
|
+
where = f"{source}: components[{index}]"
|
|
303
|
+
if not isinstance(entry, dict):
|
|
304
|
+
raise ConfigError(f"{where}: not a mapping")
|
|
305
|
+
for key in ("name", "path", "tests", "dockerfile"):
|
|
306
|
+
if not entry.get(key):
|
|
307
|
+
raise ConfigError(f"{where}: missing required key '{key}'")
|
|
308
|
+
path = str(entry["path"])
|
|
309
|
+
if not path.endswith("/"):
|
|
310
|
+
# Containment is a `startswith` test. Without the trailing slash, a component rooted at
|
|
311
|
+
# `stub/` would also claim `stubborn-notes.md`.
|
|
312
|
+
raise ConfigError(
|
|
313
|
+
f"{where}: path '{path}' must end in '/' — it is matched as a string prefix, and "
|
|
314
|
+
f"without the slash it would also match a sibling whose name merely starts with it"
|
|
315
|
+
)
|
|
316
|
+
return Component(name=str(entry["name"]), path=path,
|
|
317
|
+
tests=str(entry["tests"]), dockerfile=str(entry["dockerfile"]))
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def _grounding(entry: object, source: str, index: int) -> Grounding:
|
|
321
|
+
where = f"{source}: ground_in[{index}]"
|
|
322
|
+
if not isinstance(entry, dict):
|
|
323
|
+
raise ConfigError(f"{where}: not a mapping")
|
|
324
|
+
for key in ("name", "answers", "fetch", "into", "load"):
|
|
325
|
+
if not entry.get(key):
|
|
326
|
+
raise ConfigError(f"{where}: missing required key '{key}'")
|
|
327
|
+
fetch = entry["fetch"]
|
|
328
|
+
if not isinstance(fetch, list) or not all(isinstance(a, str) for a in fetch):
|
|
329
|
+
raise ConfigError(f"{where}: `fetch` must be a list of strings (argv), not a shell string")
|
|
330
|
+
load = str(entry["load"])
|
|
331
|
+
if load not in ("eager", "on-demand"):
|
|
332
|
+
raise ConfigError(f"{where}: load '{load}' is not one of eager, on-demand")
|
|
333
|
+
into = str(entry["into"])
|
|
334
|
+
if into.startswith("/") or ".." in Path(into).parts:
|
|
335
|
+
# `into` is written inside a clone this actor then commits from. A path that escapes it
|
|
336
|
+
# would write outside the boundary the whole containment check exists to hold.
|
|
337
|
+
raise ConfigError(f"{where}: into '{into}' must be a relative path inside the clone")
|
|
338
|
+
return Grounding(name=str(entry["name"]), answers=str(entry["answers"]),
|
|
339
|
+
fetch=tuple(fetch), into=into, load=load)
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
# ── the gate ────────────────────────────────────────────────────────────────────────────────
|
|
343
|
+
|
|
344
|
+
@dataclass
|
|
345
|
+
class Report:
|
|
346
|
+
"""What `lint` found. Errors fail; warnings are read and not acted on."""
|
|
347
|
+
|
|
348
|
+
oks: list[str]
|
|
349
|
+
warns: list[str]
|
|
350
|
+
errors: list[str]
|
|
351
|
+
|
|
352
|
+
@property
|
|
353
|
+
def ok(self) -> bool:
|
|
354
|
+
return not self.errors
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def lint(folder: str | Path = ".") -> Report:
|
|
358
|
+
"""Validate one sidecar against `foundry-implementation-actor/agentic-context/v1`.
|
|
359
|
+
|
|
360
|
+
A sidecar declaring some other `context:` is read, warned, and not checked further — the same
|
|
361
|
+
discipline papeete-actor applies to a card: UNMIGRATED is not non-conformant, and migrating is
|
|
362
|
+
the owning pair's own act.
|
|
363
|
+
"""
|
|
364
|
+
path = Path(folder)
|
|
365
|
+
if path.is_dir():
|
|
366
|
+
path = path / SIDECAR
|
|
367
|
+
report = Report(oks=[], warns=[], errors=[])
|
|
368
|
+
|
|
369
|
+
if not path.exists():
|
|
370
|
+
report.errors.append(f"{path}: no such file")
|
|
371
|
+
return report
|
|
372
|
+
try:
|
|
373
|
+
raw = yaml.safe_load(path.read_text())
|
|
374
|
+
except (OSError, yaml.YAMLError) as e:
|
|
375
|
+
report.errors.append(f"{path}: does not parse or cannot be read: {e}")
|
|
376
|
+
return report
|
|
377
|
+
if not isinstance(raw, dict):
|
|
378
|
+
report.errors.append(f"{path}: not a mapping")
|
|
379
|
+
return report
|
|
380
|
+
if raw.get("context") != CONTRACT:
|
|
381
|
+
report.warns.append(
|
|
382
|
+
f"{path}: declares '{raw.get('context')}' — UNMIGRATED, not checked against {CONTRACT}"
|
|
383
|
+
)
|
|
384
|
+
return report
|
|
385
|
+
|
|
386
|
+
try:
|
|
387
|
+
config = CapabilityConfig.from_dict(raw, source=str(path))
|
|
388
|
+
except ConfigError as e:
|
|
389
|
+
report.errors.append(str(e))
|
|
390
|
+
return report
|
|
391
|
+
|
|
392
|
+
report.oks.append(f"{path} conforms to {CONTRACT}")
|
|
393
|
+
report.oks.append(f"capability {config.capability}")
|
|
394
|
+
report.oks.append(f"actor {config.actor_name}")
|
|
395
|
+
report.oks.append(f"registry path {config.capability_path}")
|
|
396
|
+
report.oks.append(f"writes only under {', '.join(config.writes_only_under)}")
|
|
397
|
+
eager = [g.name for g in config.ground_in if g.eager]
|
|
398
|
+
if not eager and config.ground_in:
|
|
399
|
+
# Not an error: an actor may legitimately want everything on demand. But it is the shape
|
|
400
|
+
# that silently un-grounds a session, so it is said out loud.
|
|
401
|
+
report.warns.append(
|
|
402
|
+
f"{path}: no `load: eager` source — the session starts with only the on-demand list, "
|
|
403
|
+
f"and whether it reads any of them is its own choice"
|
|
404
|
+
)
|
|
405
|
+
return report
|