foundry-testing-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,168 @@
1
+ # agentic-context.schema.yaml — the foundry-testing-actor/agentic-context contract.
2
+ #
3
+ # WHAT THIS FILE IS FOR. One capability's own declaration of what this actor's non-deterministic
4
+ # doors should ground themselves in, where each component's black-box tests live, which runner
5
+ # builds them, and which implementation they are written against. The machinery in this package
6
+ # carries no capability literal of its own — every identifier it needs is DERIVED from
7
+ # `capability` and `source_repo` below (see `config.py`), so a second capability instantiates the
8
+ # same actor by writing this file and nothing else.
9
+ #
10
+ # THE CONTRACT IS COMMITTED SOURCE, NOT A FETCHED GATE. It lives inside the package, the same path
11
+ # in a source checkout and in an installed wheel, so `foundry-testing-actor lint` needs no network
12
+ # and no credential. Same discipline as papeete-actor's own manifest schema, and as the sibling
13
+ # `foundry-implementation-actor/agentic-context/v1` this one is shaped after.
14
+ #
15
+ # ─── WHY A SIDECAR AND NOT THE CARD ───────────────────────────────────────────────────────────
16
+ # `papeete_actor_synchronous_messaging.card` opens exactly four hardcoded filenames and never
17
+ # globs, so `Actor.from_card()` will never see this file. That is deliberate: this is the actor's
18
+ # own operational configuration, not part of any published card contract, and it is loaded by this
19
+ # package's `config.py` directly.
20
+ #
21
+ # ─── WHY `writes_only_under` IS NOT DECLARED HERE ─────────────────────────────────────────────
22
+ # The hand-written actor this replaces declared it beside a hardcoded copy in the module that
23
+ # ENFORCED it. There is now exactly one source: `components[].tests`. A boundary that can be stated
24
+ # twice will eventually be stated differently.
25
+ #
26
+ # ─── WHY THE READS OF `required` ARE FROM HERE ────────────────────────────────────────────────
27
+ # `config.py` reads both `required` lists below rather than carrying its own copy. The sibling
28
+ # package carried a hardcoded key tuple beside its schema's `items.required`, and noticed only when
29
+ # a removal had to touch both (ADR-FIA-0002's follow-up). This package starts without the second
30
+ # copy.
31
+
32
+ contract: foundry-testing-actor/agentic-context/v1
33
+
34
+ kind: agentic-context
35
+
36
+ location: actor-agentic-context.yaml # at the actor's folder root, beside actor.yaml
37
+
38
+ required:
39
+ - context
40
+ - engine
41
+ - capability
42
+ - source_repo
43
+ - registry_repo
44
+ - components
45
+ - ground_in
46
+
47
+ fields:
48
+
49
+ context:
50
+ type: string
51
+ const: foundry-testing-actor/agentic-context/v1
52
+ doc: >-
53
+ The contract this file claims. A file declaring some other value is read, warned, and not
54
+ checked further — UNMIGRATED is not the same as non-conformant, and migration is each
55
+ pair's own act.
56
+
57
+ engine:
58
+ type: string
59
+ doc: >-
60
+ The engine name this actor's doors name on its card (both of them). Registered under
61
+ exactly this key in `Actor.from_card(engines={...})`.
62
+
63
+ capability:
64
+ type: string
65
+ doc: >-
66
+ The capability id, in its canonical dotted form with the `CAP` segment present — e.g.
67
+ `<ENT>.<DOMAIN>.CAP.<TYPE>.<NNN>.<CODE>`. Every other rendering of the id is derived from
68
+ this one; none is declared. See `config.py` for the full table.
69
+
70
+ source_repo:
71
+ type: string
72
+ doc: >-
73
+ `<owner>/<repo>` of the TESTING repository this actor clones, writes black-box tests into,
74
+ and pushes a `test/<task_id>` branch to. The actor's own name and its git commit identity
75
+ are derived from the repo half.
76
+
77
+ implementation_repo:
78
+ type: string
79
+ optional: true
80
+ default: "<owner of source_repo>/<capability>-implementation"
81
+ doc: >-
82
+ `<owner>/<repo>` of the repository the implementation actor pushes to. Read, never written.
83
+ `propose-acceptance` reads its DEFAULT BRANCH only — the state before the increment, never
84
+ `impl/<task_id>` (ADR-FTA-0002). `test-task` checks out `impl/<task_id>` solely to recompute
85
+ the image refs that actor published; the session is not shown that source. Declare it only
86
+ when the repo does not follow the `<capability>-implementation` convention.
87
+
88
+ registry_repo:
89
+ type: string
90
+ doc: >-
91
+ `<owner>/<repo>` of the knowledge registry the `ground_in` fetches resolve through. There is
92
+ NO default: a fetch that silently reaches a fallback registry is worse than one that fails.
93
+
94
+ components:
95
+ type: array
96
+ doc: >-
97
+ The components this actor writes black-box tests for and publishes one test image of. Each
98
+ entry declares where its tests live and, optionally, which runner builds them — two things
99
+ that were hardcoded, in two different files, before this contract existed. A component's
100
+ `name` is also the folder its implementation lives under in `implementation_repo`, which is
101
+ where the ref of the image under test is recomputed from.
102
+ items:
103
+ required: [name, tests]
104
+ fields:
105
+ name:
106
+ type: string
107
+ doc: >-
108
+ The component's own name — as a caller names it in a payload's `components`, and as it
109
+ appears in both the image under test and the published test image refs.
110
+ tests:
111
+ type: string
112
+ doc: >-
113
+ The component's PERSISTENT, accumulated test root inside the testing repo, trailing
114
+ slash included (e.g. `backend/tests/`). The union of these IS the write boundary —
115
+ nothing else declares it. It is also the build context of the test image, so the image
116
+ always carries the full regression suite rather than one task's slice. May be more than
117
+ one segment deep; the component a staged path belongs to is resolved by longest
118
+ prefix.
119
+ runner:
120
+ type: string
121
+ optional: true
122
+ default: "the runner shipped in foundry-testing-actor (runner/Dockerfile)"
123
+ doc: >-
124
+ The directory holding the Dockerfile that turns this component's tests root into a
125
+ runnable image, as `buildctl --local dockerfile=` takes it — relative to the testing
126
+ repo's root, and it must exist in the clone. OMIT IT to use the runner this package
127
+ ships: a lean pytest image whose CMD is the contract the orchestrating actor runs. A
128
+ capability declares its own only when its tests need something that runner does not
129
+ carry; the declared one then owns that contract.
130
+
131
+ ground_in:
132
+ type: array
133
+ doc: >-
134
+ What the session is grounded in, before its first turn, at both doors. Each entry is a
135
+ command to run and a place to put its output inside the clone — this package hardcodes no
136
+ knowledge tool. A fourth knowledge source is an entry here, not a change to this package.
137
+ items:
138
+ required: [name, answers, fetch, into, load]
139
+ fields:
140
+ name:
141
+ type: string
142
+ doc: Short identifier for the source. Titles its section in the rendered Markdown.
143
+ answers:
144
+ type: string
145
+ doc: >-
146
+ One line saying what question this source answers. It is not decoration: for an
147
+ on-demand entry it is the ONLY thing the session sees, and what it decides to read
148
+ the file on.
149
+ fetch:
150
+ type: array
151
+ doc: >-
152
+ argv to run, as a list. `{capability}`, `{registry_repo}`, `{source_repo}` and
153
+ `{implementation_repo}` are substituted from the fields above. Non-zero exit, a missing
154
+ binary, or a timeout all fail the request before any session time is spent.
155
+ into:
156
+ type: string
157
+ doc: >-
158
+ Repo-relative path to write the envelope to, INSIDE the clone. Inside, because a
159
+ CLAUDE.md `@`-import resolves relative to the file that contains it — an envelope in
160
+ a sibling tempdir cannot be imported, only described.
161
+ load:
162
+ type: string
163
+ enum: [eager, on-demand]
164
+ doc: >-
165
+ `eager` is `@`-imported by the generated CLAUDE.md and enters the window before turn
166
+ one, unconditionally, every session. `on-demand` is listed by its `answers` line and
167
+ its path, and costs nothing until the session opens it. Choose from a measurement of
168
+ the envelope, not from taste.
@@ -0,0 +1,129 @@
1
+ """The entrypoint — moved here so two uses cannot wire their observability differently.
2
+
3
+ WHAT THIS REPLACES. A hand-written `app.py` in every use's repo: some sixty lines that configure
4
+ observability, add a console handler beside the OTLP one, install the correlation filter, read
5
+ `PORT`, build an `HttpMailbox`, call `Actor.from_card`, and emit an `actor-started` event. Not one
6
+ line of it is about the capability. Worse, the reasoning behind the strangest line — the console
7
+ handler — was discovered the hard way by an operator and then existed only as a comment someone
8
+ would have to copy correctly (ADR-FIA-0005, copied here by ADR-FTA-0001).
9
+
10
+ WHY THE IMPORTS ARE LAZY. `papeete-actor-synchronous-messaging-http` and `papeete-observability`
11
+ are how this actor is CARRIED. ADR-PAM-0001 keeps wire out of the message contract, and the same
12
+ discipline keeps an HTTP server out of the dependency set of a consumer that only wants
13
+ `CapabilityConfig`. They arrive through the `serve` extra, and importing them inside the function
14
+ is what lets `foundry-testing-actor lint` run in a venv that has neither.
15
+ """
16
+ from __future__ import annotations
17
+
18
+ import logging
19
+ import os
20
+ import sys
21
+ import tempfile
22
+ from pathlib import Path
23
+
24
+ from . import correlation
25
+ from .config import CapabilityConfig
26
+ from .engine import ClaudeCodeTesterEngine
27
+ from .handler import make_test_task
28
+ from .instance import CARD_FILES, render_cards
29
+
30
+ DEFAULT_PORT = 8080
31
+
32
+
33
+ class ServeError(RuntimeError):
34
+ """Raised when the wire half of this actor is not installed."""
35
+
36
+
37
+ def _imports():
38
+ try:
39
+ from papeete_actor_synchronous_messaging.actor import Actor
40
+ from papeete_actor_synchronous_messaging_http.mailbox import HttpMailbox
41
+ from papeete_observability import configure
42
+ except ImportError as e: # pragma: no cover - exercised in the image, not the suite
43
+ raise ServeError(
44
+ f"the `serve` extra is not installed: {e}. `foundry-testing-actor serve` needs "
45
+ f"a mailbox and an observability backend, which are wire concerns this package does "
46
+ f"not depend on by default — install `foundry-testing-actor[serve]`, or use "
47
+ f"the image this package publishes, which already has."
48
+ ) from e
49
+ return Actor, HttpMailbox, configure
50
+
51
+
52
+ def _cards_for(config: CapabilityConfig, folder: Path) -> Path:
53
+ """The folder `Actor.from_card` is pointed at.
54
+
55
+ Rendered at `docker build` time in the normal case, and this finds them already sitting beside
56
+ the sidecar. A use that skipped that step gets them rendered into a tempdir now — the cards are
57
+ derived from the definition either way, so there is no version of this that reads differently.
58
+ Build time is still the better place: it is inspectable (`cat /actor/actor.yaml`), it fails
59
+ while someone is watching a build rather than at a boot, and it needs no writable filesystem.
60
+ """
61
+ if all((folder / name).exists() for name in CARD_FILES):
62
+ return folder
63
+ rendered = Path(tempfile.mkdtemp(prefix=f"{config.actor_slug}-cards-"))
64
+ render_cards(config, rendered)
65
+ correlation.event(
66
+ "cards-rendered", folder=str(rendered), actor=config.actor_name,
67
+ because="the actor's folder carried no cards; render them at build time to have them "
68
+ "beside the sidecar instead",
69
+ )
70
+ return rendered
71
+
72
+
73
+ def serve(folder: str | Path = ".", port: int | None = None) -> None:
74
+ """Boot this actor and answer its doors until the process is stopped."""
75
+ Actor, HttpMailbox, configure = _imports()
76
+
77
+ configure()
78
+ # papeete_observability.configure() attaches an OTLP log handler to the root logger but never
79
+ # raises its level off the stdlib default WARNING, so logging.info(...) calls (including
80
+ # HttpMailbox's own access log) would otherwise never reach it.
81
+ logging.getLogger().setLevel(logging.INFO)
82
+ # ...and a console handler beside it. The OTLP handler alone is write-only from an operator's
83
+ # seat: with nothing on stdout, `kubectl logs` shows this pod's startup line and nothing else —
84
+ # so a record that never reaches the log backend is indistinguishable from a record that was
85
+ # never emitted, exactly when the telemetry backend is the thing under suspicion. Found the
86
+ # hard way, against an HTTP binding that predated its own OTel wiring and emitted no records at
87
+ # all, where `kubectl logs` could not have shown it. `StreamHandler.emit()` flushes per record,
88
+ # so this needs no PYTHONUNBUFFERED to survive a container's block-buffered stdout.
89
+ #
90
+ # The formatter applies to THIS handler only — the OTLP `LoggingHandler` builds its body from
91
+ # `record.getMessage()`, so what the log backend stores stays the bare message (for the
92
+ # engine's own stream-json records, exactly the JSON object it logged).
93
+ console = logging.StreamHandler(sys.stdout)
94
+ console.setFormatter(
95
+ correlation.ConsoleFormatter("%(asctime)s %(levelname)s %(name)s %(message)s")
96
+ )
97
+ logging.getLogger().addHandler(console)
98
+ # ...and, on both handlers, the filter that stamps every record with this request's own
99
+ # correlation ids. On the HANDLERS, not the root logger: a record from a named logger (the HTTP
100
+ # binding's own access log, most of all) reaches the root's handlers without the root LOGGER's
101
+ # filters ever seeing it — see correlation.py's own docstring.
102
+ correlation.install()
103
+
104
+ folder = Path(folder)
105
+ # The sidecar is the only thing in that folder this package did not put there.
106
+ config = CapabilityConfig.load(folder)
107
+ cards = _cards_for(config, folder)
108
+
109
+ # PORT, not a hardcoded default: an env var is how an environment moves it without editing an
110
+ # image, and it is what a Service's targetPort is set against.
111
+ port = port if port is not None else int(os.environ.get("PORT", str(DEFAULT_PORT)))
112
+ mailbox = HttpMailbox(port=port) # 0.0.0.0:<port> — one POST per door, plus GET /health
113
+ actor = Actor.from_card(
114
+ cards,
115
+ mailbox=mailbox,
116
+ # One engine instance serves every door that names one: `Actor.judge()` hands it the door
117
+ # id and it dispatches on that. The key comes from the sidecar rather than a literal here,
118
+ # so a use whose sidecar names another engine is caught by its own card.
119
+ engines={config.engine: ClaudeCodeTesterEngine(config)},
120
+ # `propose-acceptance` needs no entry: it is a query with an engine and no handler, so
121
+ # the engine's own judgement is the reply and there is no deterministic half to contain.
122
+ actions={"test-task": make_test_task(config)},
123
+ )
124
+ # An `event` record rather than a `print`: a restart in the middle of a run is one of the most
125
+ # explanatory things a pipeline panel can show, and `print` reaches only the container's own
126
+ # stdout — never the OTLP handler, so never the log backend.
127
+ correlation.event("actor-started", actor=actor.name, port=port, capability=config.capability,
128
+ cards=str(cards))
129
+ mailbox.serve_forever()
@@ -0,0 +1,258 @@
1
+ Metadata-Version: 2.5
2
+ Name: foundry-testing-actor
3
+ Version: 0.1.0
4
+ Summary: Runs a headless Claude Code black-box test-authoring session against one capability's own testing repo — a papeete-actor for one use, with the capability supplied by a sidecar.
5
+ Project-URL: Homepage, https://github.com/papeete-hub/foundry-testing-actor
6
+ Author-email: Papeete Consulting <yoann.remy@outlook.com>
7
+ License-Expression: MIT
8
+ Keywords: actor-model,agentic,capability,claude-code,papeete,testing
9
+ Classifier: Development Status :: 3 - Alpha
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: Programming Language :: Python :: 3.11
12
+ Classifier: Topic :: Software Development :: Testing
13
+ Requires-Python: >=3.11
14
+ Requires-Dist: papeete-actor-synchronous-messaging>=0.2.1
15
+ Requires-Dist: papeete-version>=0.1.0
16
+ Requires-Dist: pyyaml>=6.0
17
+ Provides-Extra: dev
18
+ Requires-Dist: pytest>=8.0; extra == 'dev'
19
+ Provides-Extra: serve
20
+ Requires-Dist: papeete-actor-synchronous-messaging-http>=0.4.0; extra == 'serve'
21
+ Requires-Dist: papeete-observability>=0.1.0; extra == 'serve'
22
+ Description-Content-Type: text/markdown
23
+
24
+ # foundry-testing-actor
25
+
26
+ Runs a headless [Claude Code](https://claude.com/claude-code) session that authors **black-box
27
+ tests** for one capability's increment, inside that capability's own testing repository — then
28
+ commits, pushes and publishes a runnable test image, inside a write boundary the actor enforces
29
+ rather than requests. Before anything is built, it proposes what it will assert.
30
+
31
+ An **actor, for one use**, with a [`papeete-actor`](https://github.com/papeete-hub/papeete-actor)
32
+ underneath. The `-actor` suffix is that claim; `papeete-actor-*` names, by contrast, are transverse
33
+ features *of* the framework (`ADR-ECO-0022`). It is the sibling of
34
+ [`foundry-implementation-actor`](https://github.com/papeete-hub/foundry-implementation-actor), and
35
+ is built exactly the way that package is.
36
+
37
+ ```bash
38
+ pip install foundry-testing-actor
39
+ ```
40
+
41
+ > **New here?** [`examples/`](examples/) walks through a complete, working use of this actor. Every
42
+ > command in it that does not build an image runs with no credentials and no network.
43
+
44
+ **It authors a harness; it does not execute it.** No session this actor starts brings up docker,
45
+ hits a live endpoint, or renders a pass/fail verdict. The orchestrating actor deploys the
46
+ increment into an ephemeral namespace, runs the test image this one published, and judges.
47
+
48
+ ## What it is
49
+
50
+ The actor's **definition** — its four cards, and the machinery behind them. It carries **no
51
+ capability of its own**: the capability it serves arrives in a sidecar the consuming repo writes:
52
+
53
+ ```yaml
54
+ # actor-agentic-context.yaml
55
+ context: foundry-testing-actor/agentic-context/v1
56
+ engine: claude-code
57
+ capability: ACME.PARTS.CAP.SUP.007.WID
58
+ source_repo: acme-lab/ACME.PARTS.CAP.SUP.007.WID-testing
59
+ registry_repo: acme-lab/acme-governance
60
+ # implementation_repo: acme-lab/ACME.PARTS.CAP.SUP.007.WID-implementation ← derived; declare to override
61
+ components:
62
+ - {name: backend, tests: backend/tests/} # the runner this package ships
63
+ - {name: stub, tests: stub/tests/, runner: deployment/stub-runner} # a runner of its own
64
+ ground_in:
65
+ - name: business
66
+ answers: the WHAT/WHY — domain vision, business events, ubiquitous language
67
+ fetch: [kpack, pack, "{capability}", --deep, --compact, --registry-repo, "{registry_repo}"]
68
+ into: .foundry/business.md
69
+ load: eager
70
+ - name: process
71
+ answers: the HOW — aggregates, commands, policies, read-models, bus, api, JSON schemas
72
+ fetch: [kontract, fetch, "{capability}", --compact, --registry-repo, "{registry_repo}"]
73
+ into: .foundry/process.md
74
+ load: eager
75
+ ```
76
+
77
+ and, beside it, a four-line Dockerfile:
78
+
79
+ ```dockerfile
80
+ FROM ghcr.io/papeete-hub/foundry-testing-actor:0.1.0
81
+ RUN pip install --no-cache-dir kpack==2.0.1 kontract==0.1.0 # what this sidecar's ground_in names
82
+ COPY actor-agentic-context.yaml /actor/
83
+ RUN foundry-testing-actor render-cards /actor && foundry-testing-actor lint /actor
84
+ ```
85
+
86
+ **That is the whole repository** — beside the tests themselves, which are what this actor writes.
87
+ No cards, no entrypoint, no Python, and no runner Dockerfile unless a component wants its own.
88
+
89
+ ### The sidecar, field by field
90
+
91
+ | field | required | default | what it is |
92
+ |---|---|---|---|
93
+ | `context` | yes | — | `foundry-testing-actor/agentic-context/v1` |
94
+ | `engine` | yes | — | the engine key both doors name; `claude-code` |
95
+ | `capability` | yes | — | the dotted id, `CAP` segment present |
96
+ | `source_repo` | yes | — | `<owner>/<repo>` of the **testing** repo: cloned, written, pushed to |
97
+ | `implementation_repo` | no | `<owner of source_repo>/<capability>-implementation` | read, never written |
98
+ | `registry_repo` | yes | none, deliberately | the knowledge registry `ground_in` fetches resolve through |
99
+ | `components[].name` | yes | — | as callers name it; also the implementation's folder for that component |
100
+ | `components[].tests` | yes | — | the persistent tests root, trailing `/`. **The write boundary**, and the test image's build context |
101
+ | `components[].runner` | no | the runner shipped in this package | a directory in the testing repo holding the runner's Dockerfile |
102
+ | `ground_in[]` | yes | — | `name`, `answers`, `fetch` (argv), `into` (inside the clone), `load` (`eager`/`on-demand`) |
103
+
104
+ **Why the runner ships in the package.** The hand-written actor kept a lean pytest Dockerfile in
105
+ its own repo that named nothing about the capability — a Python base, pytest, `requests`,
106
+ `aio-pika`, and `CMD pytest`. Its CMD is the contract between this actor and the orchestrating
107
+ actor that runs what it publishes, so it belongs with the actor. `buildctl --local dockerfile=`
108
+ reads a directory client-side, so the copy inside the installed wheel is addressed exactly as a
109
+ directory in the clone would be. A capability whose tests need more declares `runner:` and owns
110
+ that file.
111
+
112
+ ### Embedding it instead
113
+
114
+ ```python
115
+ from foundry_testing_actor import CapabilityConfig, ClaudeCodeTesterEngine, make_test_task
116
+
117
+ config = CapabilityConfig.load(".")
118
+ actor = Actor.from_card(".", mailbox=mailbox,
119
+ engines={config.engine: ClaudeCodeTesterEngine(config)},
120
+ actions={"test-task": make_test_task(config)})
121
+ ```
122
+
123
+ `propose-acceptance` needs no entry: it is a query with an engine and no handler, so the engine's
124
+ own judgement is the reply. The mailbox and observability backend `serve` needs live in the
125
+ `[serve]` extra.
126
+
127
+ ## Two doors
128
+
129
+ | door | verb | what it does |
130
+ |---|---|---|
131
+ | `propose-acceptance` | query | proposes the acceptance surface, before anything is built. Writes nothing |
132
+ | `test-task` | request | extends the suite, commits, pushes `test/<task_id>`, publishes one test image per touched component |
133
+
134
+ Both name the same engine. `Actor.judge()` hands it the door id, and it dispatches on that.
135
+
136
+ ### `propose-acceptance` — this actor's half of the three amigos round
137
+
138
+ ```
139
+ orchestration ──▶ testing: propose-acceptance {task_id, title, definition_of_done, components, context?}
140
+ ◀── {expectations: [{id, statement, handle, component?}], open_questions: [...]}
141
+ orchestration ──▶ implementation: assess-task {..., acceptance_surface: expectations}
142
+ ◀── {feasible, objections, commitments}
143
+ ```
144
+
145
+ A **query**, read-only: a clone of the testing repo, a clone of the implementation repo's **default
146
+ branch** — never `impl/<task_id>` — grounded exactly as the test door is, and a session invoked with
147
+ `Read,Glob,Grep` and nothing that writes. Its budget is the implementation actor's assess budget
148
+ (15 turns, 600 s), as constructor keywords.
149
+
150
+ The session is told three things that matter: **nothing is built yet**; where a test needs a
151
+ concrete value the task does not name — a fixture id, a routing key — **propose one**, for the
152
+ implementer to commit to or object to; and anything the task does not determine goes in
153
+ `open_questions`, **never** into an invented statement. A non-empty `open_questions` stops the round
154
+ and reaches a human. See `adr/ADR-FTA-0002-*.md`.
155
+
156
+ The reply is **projected** onto `{expectations, open_questions}` — a session's stray keys never
157
+ reach the orchestrating actor dressed as contract — after checking that every expectation carries
158
+ an `id`, a `statement` and a `handle`, with ids unique. `open_questions` is always a list.
159
+
160
+ ### `test-task`
161
+
162
+ ```
163
+ clone the testing repo (full) → checkout -b test/TASK-NNN
164
+ clone the implementation repo → checkout impl/TASK-NNN, recompute each image under test (never shown to the session)
165
+ run every ground_in fetch, write it into the testing clone, render CLAUDE.md
166
+ claude --print --output-format stream-json --permission-mode acceptEdits
167
+ git add <each tests root>; refuse anything staged outside them
168
+ commit as the actor, force-push test/TASK-NNN
169
+ buildctl build + push one test image per touched component
170
+ ```
171
+
172
+ With an `acceptance_surface`, the prompt says it was agreed with the implementer before anything
173
+ was built: each expectation is asserted by its id, addressed exactly via its handle, wins where it
174
+ is more specific than the DoD, and is named in each test. With a `remediation_context`, the session
175
+ decides per failing criterion whether the test or the implementation is at fault, and fixes only
176
+ the former.
177
+
178
+ It never opens a pull request, and never runs what it wrote beyond `pytest --collect-only`.
179
+
180
+ ## Derived renderings, zero literals
181
+
182
+ `foundry-testing-actor show` prints them for a given sidecar:
183
+
184
+ | rendering | from |
185
+ |---|---|
186
+ | `ACME.PARTS.CAP.SUP.007.WID-testing` | actor name, git `user.name` (repo half of `source_repo`) |
187
+ | `acme-lab/ACME.PARTS.CAP.SUP.007.WID-implementation` | `implementation_repo`, when not declared |
188
+ | `acme.parts/sup.007.wid` | the id, split at its `CAP` segment |
189
+ | `acme.parts.cap.sup.007.wid-backend-tests` | the name `papeete-version` versions the test image under |
190
+ | `<registry>/acme.parts/sup.007.wid/backend/tests:<version>` | **the test image this actor publishes** |
191
+ | `<registry>/acme.parts/sup.007.wid/backend:<version>` | **the image under test**, recomputed |
192
+
193
+ **Both refs are a three-way contract.** The implementation actor publishes the second; the
194
+ orchestrating actor runs the first and parses both apart. Derivation output or nothing.
195
+
196
+ ## Grounding, containment, observability
197
+
198
+ Identical to `foundry-implementation-actor`'s, deliberately copied rather than shared
199
+ (ADR-FTA-0001): envelopes are written **inside the clone** and `@`-imported from a generated
200
+ `CLAUDE.md` (appended to a committed one, never substituting it); containment is `git add <root>`
201
+ then a prefix assertion with longest-prefix component resolution; every emitted log line is
202
+ budgeted to 64 KB; the record schema `{event: step|event|result, ...}` plus `correlation_id` and
203
+ `task_id` is the contract, step names are not.
204
+
205
+ Step names this actor emits: `clone-tests`, `clone-code`, `ground-<name>`, `claude-session` or
206
+ `propose-session`, `containment-commit`, `push-branch`, `publish-test-image`, plus the events
207
+ `images-under-test`, `acceptance-proposed`, `components-touched`, `test-image-published`,
208
+ `test-task-refused`.
209
+
210
+ ## Credentials
211
+
212
+ | variable | what for |
213
+ |---|---|
214
+ | `GITHUB_TOKEN` | fine-grained PAT: `contents:write` on `source_repo`; read-only `contents` on `implementation_repo` and on whatever the `ground_in` fetches resolve through |
215
+ | `CLAUDE_CODE_OAUTH_TOKEN` | from `claude setup-token`, tied to a Pro/Max/Team/Enterprise subscription |
216
+ | `IMAGE_REGISTRY`, `BUILDKIT_HOST`, `DOCKER_CONFIG` | `test-task` only: where images are recomputed from and pushed to |
217
+
218
+ > **Do not also set `ANTHROPIC_API_KEY` or `ANTHROPIC_AUTH_TOKEN`.** In `claude -p`
219
+ > non-interactive mode an API key present in the environment is ALWAYS preferred over
220
+ > `CLAUDE_CODE_OAUTH_TOKEN`, silently routing every session through metered billing.
221
+
222
+ ## CLI
223
+
224
+ ```bash
225
+ foundry-testing-actor lint . # validate the sidecar and the cards
226
+ foundry-testing-actor show . --registry reg.example.com # every derived rendering
227
+ foundry-testing-actor render-cards . # write the four cards from the wheel
228
+ foundry-testing-actor serve . # boot it (needs the `serve` extra)
229
+ ```
230
+
231
+ ## Where this came from
232
+
233
+ Ported from one capability's hand-written testing actor — an `app.py`, an engine that named two
234
+ knowledge tools and their registry as module constants, a handler whose write boundary and
235
+ capability path were module constants too, four cards, and a runner Dockerfile — using
236
+ `foundry-implementation-actor` as the template. `adr/` records the decisions.
237
+
238
+ ## Releasing, and which registry to pin
239
+
240
+ A tag (`v*`) publishes the wheel to PyPI and the image to two registries holding one digest, as
241
+ `foundry-implementation-actor` does (ADR-FIA-0006):
242
+
243
+ | Registry | Pin it when |
244
+ |---|---|
245
+ | `ghcr.io/papeete-hub/foundry-testing-actor` | you write a use's Dockerfile yourself and build it with your own Docker |
246
+ | a product's own registry, `vars.PRODUCT_IMAGE` | the use is built in-cluster, by a builder whose one registry credential is that product's |
247
+
248
+ A manual run backfills an image for an existing tag: `gh workflow run release.yml --ref main -f
249
+ tag=v0.1.0`. It skips PyPI and does not move `latest`.
250
+
251
+ ## Development
252
+
253
+ ```bash
254
+ uv run --extra dev pytest -q # what CI runs
255
+ uv build
256
+ ```
257
+
258
+ There is no separate lint/format command configured in this repo.
@@ -0,0 +1,20 @@
1
+ foundry_testing_actor/__init__.py,sha256=S2AERZkWdEbqmoVoNzDixWM22NsepCHYFKnf_8eW6qc,2308
2
+ foundry_testing_actor/cli.py,sha256=dxOIrjONTD8gtEk36JXtVTIvNF68OmqD5-gGr5GWun8,7621
3
+ foundry_testing_actor/config.py,sha256=iBWT80hiB2yP4IYcbrO69lDfvK2Eg7XhAotGUoh7Hig,25324
4
+ foundry_testing_actor/conformance.py,sha256=8WT_T75hJXRnoRlitqLE1JcM_kJTxiwQTrftIOad75E,7333
5
+ foundry_testing_actor/correlation.py,sha256=PmnlGDKzu9ZBB8NEHaB4mNTimYZSHdWw31BNFbb0nNo,10658
6
+ foundry_testing_actor/engine.py,sha256=cg9Yff2eq2O-79TXpa1-SE6EwxYnyNuE3meDkaEH4A0,50309
7
+ foundry_testing_actor/grounding.py,sha256=JIj8K15TR1ctXNrjoeb1SLS8rrVe4YObrLuo011i8mM,9502
8
+ foundry_testing_actor/handler.py,sha256=Yor47nNr2Mge0GdoAAwbVPC5hwYWcr3wjSWmjo3UrIo,12466
9
+ foundry_testing_actor/instance.py,sha256=hSQ2bP0dj7fs_AuGiiZP063f_iI64gp7VU-aBeMxD98,6708
10
+ foundry_testing_actor/serve.py,sha256=llRur42JG5E8h34Vs4e9MpXB02JzYSFif-dpwN6ngAk,7073
11
+ foundry_testing_actor/cards/actor-data.yaml,sha256=isEMJwEAwqNCmCKGbC3dKgpq56JMm_Y9DUkLlxl0sDw,3737
12
+ foundry_testing_actor/cards/actor-message.yaml,sha256=KW_uNIUJQ05UfVnodIh-xoa4uM_KRpnVcHEz8cJcsUY,1535
13
+ foundry_testing_actor/cards/actor-synchronous-messaging.yaml,sha256=dlY6QCj_uioZxQ2YXfxyjE0MwWZTfoc7jVQnjP6rGwE,4149
14
+ foundry_testing_actor/cards/actor.yaml,sha256=iSpegjN7VT_8oocfgX_Ily4GmUqzu-33GF9fgvAGDYM,1373
15
+ foundry_testing_actor/runner/Dockerfile,sha256=lCGX5wMRmAi--AbiMcXi9A6IA75p45DjgvtaATYVKg8,1955
16
+ foundry_testing_actor/schemas/agentic-context.schema.yaml,sha256=0UeeYF8TtT4C4SO0w_dr6f3QezyqK-tXqPLWRqQAtcQ,8629
17
+ foundry_testing_actor-0.1.0.dist-info/METADATA,sha256=26pfkedlhhqOaT-udJS-RIWA0dXb7oLEPa9qIPwMzTU,13505
18
+ foundry_testing_actor-0.1.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
19
+ foundry_testing_actor-0.1.0.dist-info/entry_points.txt,sha256=mmZNR9DfIhAQu3KXB-ruHwWNrmBsRjxQawxr4xZ8JQg,73
20
+ foundry_testing_actor-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ foundry-testing-actor = foundry_testing_actor.cli:main