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,197 @@
1
+ """Grounding — putting the capability's own context in front of the session before turn one.
2
+
3
+ THE MECHANISM, AND WHY IT IS THIS ONE. `CLAUDE.md` at the working directory's root is loaded by
4
+ the `claude` CLI **before the first turn**, and its `@relative/path.md` imports are resolved
5
+ eagerly at the same moment. Verified live in this actor's exact invocation shape (`--print
6
+ --output-format stream-json --permission-mode acceptEdits`): a question only the fetched context
7
+ could answer came back in `num_turns: 1` with zero tool calls. Nothing was read, because nothing
8
+ needed to be — it was already in the window.
9
+
10
+ WHAT THIS REPLACES. The previous arrangement fetched the same envelopes into a tempdir **beside**
11
+ the clone and asked the session, in prose, to "read it before you start". That is not broken —
12
+ reads outside the cwd work and no permission denial was ever recorded — but it is *advisory*.
13
+ Whether the context entered the window at all was the model's choice, re-made every session, and
14
+ a session that skipped it looked exactly like one that had read it. Writing the envelopes inside
15
+ the clone and naming them from a generated `CLAUDE.md` turns grounding from a request into a
16
+ precondition.
17
+
18
+ WHY INSIDE THE CLONE, SPECIFICALLY. A `@`-import resolves relative to the file that contains it.
19
+ An envelope in a sibling tempdir can be *described* to a session but never imported by one, so
20
+ the sibling-tempdir arrangement could not have been fixed by writing a better prompt.
21
+
22
+ TWO TIERS, AND WHY THE CHOICE IS A MEASUREMENT. `load: eager` costs its full token weight on
23
+ every session, unconditionally, whether or not the task touches what it describes. `load:
24
+ on-demand` costs one line — its `answers:` and its path — and the session pays the rest only if
25
+ it opens the file. Neither is the right default in general; the right one for a given source is
26
+ whatever its measured envelope size says. One such read was already recorded as putting "40 kB of
27
+ JSON on screen", so this is not a hypothetical budget.
28
+
29
+ CONTAINMENT IS UNAFFECTED. The handler stages only `components[].path`, so everything written
30
+ here — the `.foundry/` envelopes and the generated `CLAUDE.md` alike — is never staged, never
31
+ committed, and dies with the clone. No `.gitignore` entry is needed, and adding one would be a
32
+ second place to state a boundary that is already stated once.
33
+ """
34
+ from __future__ import annotations
35
+
36
+ import json
37
+ import subprocess
38
+ from pathlib import Path
39
+
40
+ from papeete_actor_synchronous_messaging.engine import EngineError
41
+
42
+ from .config import CapabilityConfig, Grounding
43
+
44
+ CLAUDE_MD = "CLAUDE.md"
45
+
46
+ DEFAULT_FETCH_TIMEOUT_S = 120
47
+
48
+ # The marker that lets a generated block be told apart from prose a repo wrote for itself. It is
49
+ # not parsed — nothing here ever edits a previous block, because every clone is fresh — but a
50
+ # human reading a session's clone should be able to see instantly which half is machine-written.
51
+ _BEGIN = "<!-- foundry-implementation-actor: generated grounding -->"
52
+
53
+
54
+ def ground(config: CapabilityConfig, clone_dir: Path, *,
55
+ timeout: int = DEFAULT_FETCH_TIMEOUT_S) -> list[Grounding]:
56
+ """Fetch every `ground_in` source, write it into the clone, and render `CLAUDE.md`.
57
+
58
+ Returns the entries that were grounded, in declaration order. Raises `EngineError` if any
59
+ fetch fails — a session grounded in half its context is worse than one that never started,
60
+ because only the second is visible.
61
+ """
62
+ for entry in config.ground_in:
63
+ envelope = fetch(config, entry, timeout=timeout)
64
+ write_envelope(config, entry, clone_dir, envelope)
65
+ render_claude_md(config, clone_dir)
66
+ return list(config.ground_in)
67
+
68
+
69
+ def fetch(config: CapabilityConfig, entry: Grounding, *,
70
+ timeout: int = DEFAULT_FETCH_TIMEOUT_S) -> str:
71
+ """Run one entry's `fetch:` argv and return its stdout.
72
+
73
+ This package knows no knowledge tool by name. It knows "run this, ground the session in what
74
+ comes back" — which is why a fourth source is a YAML entry rather than a change here.
75
+ """
76
+ argv = config.expand(entry.fetch)
77
+ try:
78
+ result = subprocess.run(argv, capture_output=True, text=True, timeout=timeout)
79
+ except FileNotFoundError as e:
80
+ raise EngineError(
81
+ f"grounding '{entry.name}': '{argv[0]}' is not on PATH. The sidecar names the tools "
82
+ f"this capability grounds itself in; installing them is the consuming image's job, "
83
+ f"not this package's."
84
+ ) from e
85
+ except subprocess.TimeoutExpired as e:
86
+ raise EngineError(
87
+ f"grounding '{entry.name}': {' '.join(argv)} timed out after {timeout}s"
88
+ ) from e
89
+ if result.returncode != 0:
90
+ raise EngineError(
91
+ f"grounding '{entry.name}': {' '.join(argv)} failed: {result.stderr.strip()}"
92
+ )
93
+ return result.stdout
94
+
95
+
96
+ def write_envelope(config: CapabilityConfig, entry: Grounding, clone_dir: Path,
97
+ envelope: str) -> Path:
98
+ """Write one fetched envelope into the clone, as Markdown wrapping the raw payload.
99
+
100
+ Markdown rather than the bare `.json` the previous arrangement wrote, for one reason: a
101
+ `CLAUDE.md` `@`-import pulls in a file whole, so the file has to carry its own title and its
102
+ own "what this answers" line, or the session receives a wall of JSON with no idea which
103
+ question it settles. The payload itself is untouched inside the fence.
104
+ """
105
+ destination = clone_dir / entry.into
106
+ destination.parent.mkdir(parents=True, exist_ok=True)
107
+ destination.write_text(
108
+ f"# {entry.name}\n\n"
109
+ f"{entry.answers}\n\n"
110
+ f"Fetched fresh for this session by `{' '.join(config.expand(entry.fetch))}`. Read it as "
111
+ f"given — it is this capability's own standing context, not something to re-derive.\n\n"
112
+ f"```json\n{_pretty(envelope)}\n```\n"
113
+ )
114
+ return destination
115
+
116
+
117
+ def render_claude_md(config: CapabilityConfig, clone_dir: Path) -> Path:
118
+ """Write the clone's `CLAUDE.md`, or append to one the repo already commits.
119
+
120
+ APPEND, NEVER OVERWRITE. The capability repo has no `CLAUDE.md` of its own today, which is
121
+ exactly why this is safe to introduce now — there is nothing to clobber, so the clean-slate
122
+ case is the one being exercised. The day one is committed, it is the repo's own standing
123
+ guidance for anyone working in it, and silently replacing it with a generated block would
124
+ remove the very thing a session most needs to obey.
125
+ """
126
+ body = _claude_md_body(config)
127
+ path = clone_dir / CLAUDE_MD
128
+ if path.exists():
129
+ existing = path.read_text().rstrip("\n")
130
+ path.write_text(f"{existing}\n\n---\n\n{body}")
131
+ else:
132
+ path.write_text(body)
133
+ return path
134
+
135
+
136
+ def _claude_md_body(config: CapabilityConfig) -> str:
137
+ eager = [g for g in config.ground_in if g.eager]
138
+ on_demand = [g for g in config.ground_in if not g.eager]
139
+
140
+ lines = [
141
+ _BEGIN,
142
+ "",
143
+ f"# {config.capability} — context for this session",
144
+ "",
145
+ "You are working inside a private clone made for one task. The context below was fetched "
146
+ "fresh for this session from this capability's own knowledge registry. It is authoritative: "
147
+ "read it as given rather than re-deriving what it already answers.",
148
+ "",
149
+ ]
150
+
151
+ if eager:
152
+ lines += ["## Standing context", ""]
153
+ for entry in eager:
154
+ lines.append(f"{entry.answers}:")
155
+ lines.append("")
156
+ lines.append(f"@{entry.into}")
157
+ lines.append("")
158
+
159
+ if on_demand:
160
+ lines += [
161
+ "## Available on demand",
162
+ "",
163
+ "Not loaded. Open the file if the task needs what it answers.",
164
+ "",
165
+ ]
166
+ for entry in on_demand:
167
+ lines.append(f"- **{entry.name}** — {entry.answers}. `{entry.into}`")
168
+ lines.append("")
169
+
170
+ lines += [
171
+ "## Boundaries",
172
+ "",
173
+ f"Write ONLY under {_join(config.writes_only_under)}. Nothing else in this clone is yours "
174
+ f"to change, and a staged path outside those roots is refused rather than committed.",
175
+ "",
176
+ "Do not `git add`, `git commit`, or `git push` — that is handled outside this session.",
177
+ "",
178
+ ]
179
+ return "\n".join(lines) + "\n"
180
+
181
+
182
+ def _pretty(envelope: str) -> str:
183
+ """Pretty-print a JSON envelope; pass anything else through untouched.
184
+
185
+ The fetch contract is "whatever the tool prints", not "JSON" — a source that emits Markdown or
186
+ plain text is equally groundable, and reformatting is a convenience for the JSON case, never a
187
+ requirement placed on the tool.
188
+ """
189
+ text = envelope.strip()
190
+ try:
191
+ return json.dumps(json.loads(text), indent=2, ensure_ascii=False)
192
+ except (json.JSONDecodeError, ValueError):
193
+ return text
194
+
195
+
196
+ def _join(items) -> str:
197
+ return ", ".join(items)
@@ -0,0 +1,220 @@
1
+ """`implement_task` — the deterministic, auditable half of an implementation door.
2
+
3
+ `ClaudeCodeEngine.judge()` embodies the creative decision: what to build, grounded in the caller's
4
+ own payload and in the capability's own standing context. Everything here is mechanical and never
5
+ trusted to the engine's own judgement:
6
+
7
+ - **Containment.** `git add <component root>` for each declared component — never `-A`, never a
8
+ bare `.` — then assert every `git diff --cached --name-only` path starts with one of those
9
+ roots. This check, not the session's own write-boundary instruction, is what actually enforces
10
+ the write boundary. A staged path outside it is refused rather than committed.
11
+ - **The correlation id.** `TASK-NNN` is threaded through the branch name (from the engine), the
12
+ commit message, and — via `correlation.py`, bound at the top of this door — every log record
13
+ this actor emits for the request, alongside the trace id its caller propagated.
14
+ - **Publishing.** For each component the commit actually touched, this actor builds an image in
15
+ the cluster's shared buildkit and pushes it to the registry, named and versioned by convention
16
+ (`papeete_version.compute`) — no explicit tag is ever handed to a caller. No Docker daemon is
17
+ involved anywhere: `buildctl` reaches `BUILDKIT_HOST`, which is why an actor running this can
18
+ be an ordinary Pod.
19
+ - **Never opens a PR.** This door accepts, pushes, and publishes. Rendering a verdict and opening
20
+ a pull request belongs to whichever actor orchestrates the pipeline, once its other members
21
+ have also confirmed.
22
+
23
+ THE BOUNDARY IS READ, NOT RESTATED. `WRITES_ONLY_UNDER` used to be a module constant here, kept
24
+ in sync by hand with the sidecar's own declaration of the same fact. It is now
25
+ `config.writes_only_under`, derived from `components[].path`. There is one source, and this file
26
+ is not it.
27
+ """
28
+ from __future__ import annotations
29
+
30
+ import os
31
+ import shutil
32
+ import subprocess
33
+ from pathlib import Path
34
+
35
+ from papeete_version.version import compute as compute_version
36
+ from papeete_version.version import normalize_name
37
+
38
+ from . import correlation
39
+ from .config import CapabilityConfig
40
+
41
+
42
+ class HandlerError(RuntimeError):
43
+ """Raised for anything that stops this door short — including a containment violation.
44
+
45
+ `Actor.receive()` turns an uncaught exception from a handler into a `Refusal`
46
+ (`{self.name}'s own handler for '{offer.id}' failed: {e}`), which the HTTP binding replies as
47
+ 400 — the same path an undeclared door or a schema violation already takes. A containment
48
+ breach is therefore refused, never silently swallowed or half-committed.
49
+ """
50
+
51
+
52
+ def make_implement_task(config: CapabilityConfig):
53
+ """Bind one capability's config to the `implement-task` handler.
54
+
55
+ A factory rather than a module-level function reading `actor.engines["claude-code"]`: the
56
+ engine's registered name comes from the sidecar, so looking the config up through a hardcoded
57
+ engine key would reintroduce exactly the literal this package exists to remove.
58
+ """
59
+
60
+ def implement_task(actor, payload: dict, from_: str, judged: dict | None = None) -> dict:
61
+ # Bound again here, not only in the engine's own `judge()`: a refusal path reaches this
62
+ # door with `judged=None`, having never entered the engine at all, and that refusal is
63
+ # exactly the record you want carrying a task id.
64
+ correlation.bind(correlation_id=correlation.correlation_id(),
65
+ task_id=payload.get("task_id"), door="implement-task", caller=from_)
66
+
67
+ if judged is None or not judged.get("implemented"):
68
+ reason = (judged or {}).get("reason", "not eligible")
69
+ correlation.event("implement-task-refused", because=reason)
70
+ return {"accepted": False, "because": reason}
71
+
72
+ task_id = payload["task_id"]
73
+ clone_dir = Path(judged["clone_dir"])
74
+ branch = judged["branch"]
75
+ summary = judged.get("summary", "")
76
+
77
+ try:
78
+ with correlation.stage("containment-commit",
79
+ writes_only_under=list(config.writes_only_under)):
80
+ components = _containment_commit(config, clone_dir, task_id, summary)
81
+ correlation.event("components-touched", components=components)
82
+ with correlation.stage("push-branch", branch=branch, repo=config.source_repo):
83
+ _push(config, clone_dir, branch, _github_token(actor, config))
84
+ images = []
85
+ for component in components:
86
+ with correlation.stage("publish-image", component=component):
87
+ images.append(_publish_image(config, clone_dir, component, task_id))
88
+ correlation.event("image-published", component=component, image=images[-1])
89
+ return {"accepted": True, "branch": branch, "images": images}
90
+ finally:
91
+ shutil.rmtree(clone_dir, ignore_errors=True)
92
+
93
+ return implement_task
94
+
95
+
96
+ def _github_token(actor, config: CapabilityConfig) -> str:
97
+ engine = actor.engines.get(config.engine)
98
+ token = getattr(engine, "github_token", None) or os.environ.get("GITHUB_TOKEN")
99
+ if not token:
100
+ raise HandlerError("no GITHUB_TOKEN available (neither on the engine nor the environment)")
101
+ return token
102
+
103
+
104
+ def _redact(text: str, secret: str) -> str:
105
+ return text.replace(secret, "***")
106
+
107
+
108
+ def _run(clone_dir: Path, args: list[str], *, redact: str | None = None) -> str:
109
+ try:
110
+ result = subprocess.run(args, cwd=clone_dir, check=True, capture_output=True, text=True)
111
+ except subprocess.CalledProcessError as e:
112
+ stderr = _redact(e.stderr, redact) if redact else e.stderr
113
+ raise HandlerError(f"{' '.join(args[:2])} failed: {stderr}") from e
114
+ return result.stdout
115
+
116
+
117
+ def _containment_commit(config: CapabilityConfig, clone_dir: Path,
118
+ task_id: str, summary: str) -> list[str]:
119
+ boundary = config.writes_only_under
120
+ for path in boundary:
121
+ # `git add` errors hard (pathspec did not match any files) on a prefix that doesn't exist
122
+ # on disk AT ALL, not just one with zero changes — verified live against a sibling actor
123
+ # that hit exactly this for a tests/ root before any task had touched it. The guard is
124
+ # cheap and keeps two containment checks in different repos genuinely identical.
125
+ if (clone_dir / path).exists():
126
+ _run(clone_dir, ["git", "add", path])
127
+ staged = [line for line in _run(clone_dir, ["git", "diff", "--cached", "--name-only"])
128
+ .splitlines() if line]
129
+
130
+ offending = [p for p in staged if not any(p.startswith(prefix) for prefix in boundary)]
131
+ if offending:
132
+ _run(clone_dir, ["git", "reset"])
133
+ raise HandlerError(
134
+ f"{task_id}: refusing to commit — staged path(s) outside "
135
+ f"{', '.join(boundary)}: {offending}"
136
+ )
137
+ if not staged:
138
+ raise HandlerError(
139
+ f"{task_id}: nothing staged under {', '.join(boundary)} — the session made no change "
140
+ f"there"
141
+ )
142
+
143
+ message = f"feat({task_id}): implemented by {config.actor_name}\n\nTask: {task_id}"
144
+ if summary:
145
+ message += f"\n\n{summary}"
146
+ _run(clone_dir, [
147
+ "git", "-c", f"user.name={config.git_author_name}",
148
+ "-c", f"user.email={config.git_author_email}",
149
+ "commit", "-m", message,
150
+ ])
151
+
152
+ # Which component a staged path belongs to is resolved by LONGEST declared prefix, not by
153
+ # taking the path's first segment. The first-segment shortcut is correct only while every
154
+ # component root is one segment deep, and reports the wrong component — silently — the day
155
+ # one of them is `src/gateway/`.
156
+ return config.components_for(staged)
157
+
158
+
159
+ def _push(config: CapabilityConfig, clone_dir: Path, branch: str, token: str) -> None:
160
+ push_url = f"https://x-access-token:{token}@github.com/{config.source_repo}.git"
161
+ _run(clone_dir, ["git", "push", "--force", push_url, f"HEAD:refs/heads/{branch}"],
162
+ redact=token)
163
+
164
+
165
+ def _image_registry() -> str:
166
+ registry = os.environ.get("IMAGE_REGISTRY")
167
+ if not registry:
168
+ raise HandlerError(
169
+ "no IMAGE_REGISTRY set — this actor publishes to a registry, and refuses to build an "
170
+ "image nothing else could ever pull"
171
+ )
172
+ return registry.rstrip("/")
173
+
174
+
175
+ def _publish_image(config: CapabilityConfig, clone_dir: Path,
176
+ component: str, task_id: str) -> str:
177
+ """Build this component's own image in the cluster's shared buildkit and push it — versioned
178
+ by convention, never an explicitly-passed tag.
179
+
180
+ No Docker daemon and no docker socket: `buildctl` talks to the buildkitd Service named by
181
+ `BUILDKIT_HOST`, which holds the push credential itself. The build runs where buildkitd runs;
182
+ only the ref comes back.
183
+
184
+ Pushed rather than left local because the pod that runs this image is not this pod — it is a
185
+ container in an ephemeral namespace the orchestrating actor stands up later, which can only
186
+ reach the image through a registry.
187
+
188
+ THE REF IS A CONTRACT WITH ACTORS THIS PACKAGE NEVER SEES. Its peers recompute the identical
189
+ string and parse it back apart, so it is `config.image_ref` output or nothing. Never invent a
190
+ tag scheme here.
191
+ """
192
+ declared = config.component_for(f"{component}/")
193
+ if declared is None: # unreachable via `components_for`, cheap to hold
194
+ raise HandlerError(f"{component}: not a declared component of {config.capability}")
195
+
196
+ dockerfile_dir = clone_dir / declared.dockerfile
197
+ if not dockerfile_dir.is_dir():
198
+ # Late failure here is expensive: the commit has already landed and the branch is already
199
+ # pushed. Saying which declared path is missing beats `buildctl`'s own error, which names
200
+ # a temp path the operator has no way to map back to the sidecar.
201
+ raise HandlerError(
202
+ f"{component}: declared dockerfile directory '{declared.dockerfile}' does not exist "
203
+ f"in the clone — the sidecar and the repo disagree"
204
+ )
205
+
206
+ version = compute_version(
207
+ folder=clone_dir / declared.path.rstrip("/"),
208
+ name=config.image_name(component),
209
+ label="feature",
210
+ feature_name=normalize_name(task_id),
211
+ )
212
+ image = config.image_ref(_image_registry(), component, version)
213
+ _run(clone_dir, [
214
+ "buildctl", "build",
215
+ "--frontend", "dockerfile.v0",
216
+ "--local", f"context={declared.path.rstrip('/')}",
217
+ "--local", f"dockerfile={declared.dockerfile}",
218
+ "--output", f"type=image,name={image},push=true",
219
+ ])
220
+ return image
@@ -0,0 +1,143 @@
1
+ # agentic-context.schema.yaml — the foundry-implementation-actor/agentic-context contract.
2
+ #
3
+ # WHAT THIS FILE IS FOR. One capability's own declaration of what a non-deterministic door should
4
+ # ground itself in, where it may write, and what it publishes. The machinery in this package
5
+ # carries no capability literal of its own — every identifier it needs is DERIVED from `capability`
6
+ # and `source_repo` below (see `config.py`), so a second capability instantiates the same actor by
7
+ # writing this file and nothing else.
8
+ #
9
+ # THE CONTRACT IS COMMITTED SOURCE, NOT A FETCHED GATE. It lives inside the package, the same path
10
+ # in a source checkout and in an installed wheel, so `foundry-implementation-actor lint` needs no
11
+ # network and no credential. Same discipline as papeete-actor's own manifest schema.
12
+ #
13
+ # ─── WHY A SIDECAR AND NOT THE CARD ───────────────────────────────────────────────────────────
14
+ # `papeete_actor_synchronous_messaging.card` opens exactly four hardcoded filenames and never
15
+ # globs, so `Actor.from_card()` will never see this file. That is deliberate: this is the actor's
16
+ # own operational configuration, not part of any published card contract, and it is loaded by this
17
+ # package's `config.py` directly.
18
+ #
19
+ # ─── WHY `writes_only_under` IS NOT DECLARED HERE ─────────────────────────────────────────────
20
+ # It used to be, beside `components:`, and the two drifted — the sidecar declared one list while
21
+ # the file that actually ENFORCES containment carried its own hardcoded copy. There is now exactly
22
+ # one source: `components[].path`. A boundary that can be stated twice will eventually be stated
23
+ # differently.
24
+
25
+ contract: foundry-implementation-actor/agentic-context/v1
26
+
27
+ kind: agentic-context
28
+
29
+ location: actor-agentic-context.yaml # at the actor's folder root, beside actor.yaml
30
+
31
+ required:
32
+ - context
33
+ - engine
34
+ - capability
35
+ - source_repo
36
+ - registry_repo
37
+ - components
38
+ - ground_in
39
+
40
+ fields:
41
+
42
+ context:
43
+ type: string
44
+ const: foundry-implementation-actor/agentic-context/v1
45
+ doc: >-
46
+ The contract this file claims. A file declaring some other value is read, warned, and not
47
+ checked further — UNMIGRATED is not the same as non-conformant, and migration is each
48
+ pair's own act.
49
+
50
+ engine:
51
+ type: string
52
+ doc: >-
53
+ The engine name this actor's non-deterministic door names on its card. Registered under
54
+ exactly this key in `Actor.from_card(engines={...})`.
55
+
56
+ capability:
57
+ type: string
58
+ doc: >-
59
+ The capability id, in its canonical dotted form with the `CAP` segment present — e.g.
60
+ `<ENT>.<DOMAIN>.CAP.<TYPE>.<NNN>.<CODE>`. Every other rendering of the id is derived from
61
+ this one; none is declared. See `config.py` for the full table.
62
+
63
+ source_repo:
64
+ type: string
65
+ doc: >-
66
+ `<owner>/<repo>` of the repository this actor clones, writes to, and pushes a branch to.
67
+ The actor's own name and its git commit identity are derived from the repo half.
68
+
69
+ registry_repo:
70
+ type: string
71
+ doc: >-
72
+ `<owner>/<repo>` of the knowledge registry the `ground_in` fetches resolve through. There is
73
+ NO default: a fetch that silently reaches a fallback registry is worse than one that fails.
74
+
75
+ components:
76
+ type: array
77
+ doc: >-
78
+ The units this actor may write to and publish. Each entry declares its own boundary, its own
79
+ test suite, and its own Dockerfile context — the three things that were hardcoded, in three
80
+ different files, before this contract existed.
81
+ items:
82
+ required: [name, path, tests, dockerfile]
83
+ fields:
84
+ name:
85
+ type: string
86
+ doc: The component's own name. Appears in the published image ref.
87
+ path:
88
+ type: string
89
+ doc: >-
90
+ Its root inside the repo, trailing slash included. The union of these IS the write
91
+ boundary — nothing else declares it. May be more than one segment deep; the
92
+ component a staged path belongs to is resolved by longest prefix, not by taking
93
+ the path's first segment.
94
+ tests:
95
+ type: string
96
+ doc: >-
97
+ The component's own test suite, named in the session's prompt so the instruction to
98
+ iterate it green is not a hardcoded literal in an f-string.
99
+ dockerfile:
100
+ type: string
101
+ doc: >-
102
+ The directory holding this component's Dockerfile, as `buildctl --local dockerfile=`
103
+ takes it. Must exist in the clone, or publishing this component fails late, after a
104
+ commit and a push have already landed.
105
+
106
+ ground_in:
107
+ type: array
108
+ doc: >-
109
+ What the session is grounded in, before its first turn. Each entry is a command to run and a
110
+ place to put its output inside the clone — this package hardcodes no knowledge tool. A
111
+ fourth knowledge source is an entry here, not a change to this package.
112
+ items:
113
+ required: [name, answers, fetch, into, load]
114
+ fields:
115
+ name:
116
+ type: string
117
+ doc: Short identifier for the source. Titles its section in the rendered Markdown.
118
+ answers:
119
+ type: string
120
+ doc: >-
121
+ One line saying what question this source answers. It is not decoration: for an
122
+ on-demand entry it is the ONLY thing the session sees, and what it decides to read
123
+ the file on.
124
+ fetch:
125
+ type: array
126
+ doc: >-
127
+ argv to run, as a list. `{capability}`, `{registry_repo}` and `{source_repo}` are
128
+ substituted from the fields above. Non-zero exit, a missing binary, or a timeout all
129
+ fail the request before any session time is spent.
130
+ into:
131
+ type: string
132
+ doc: >-
133
+ Repo-relative path to write the envelope to, INSIDE the clone. Inside, because a
134
+ CLAUDE.md `@`-import resolves relative to the file that contains it — an envelope in
135
+ a sibling tempdir cannot be imported, only described.
136
+ load:
137
+ type: string
138
+ enum: [eager, on-demand]
139
+ doc: >-
140
+ `eager` is `@`-imported by the generated CLAUDE.md and enters the window before turn
141
+ one, unconditionally, every session. `on-demand` is listed by its `answers` line and
142
+ its path, and costs nothing until the session opens it. Choose from a measurement of
143
+ the envelope, not from taste.