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.
@@ -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