unaltraweb 0.7.4 → 0.8.1

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,287 @@
1
+ """Opt-in, version-bound #121 review against the published 0.8.0 controller.
2
+
3
+ Creates synthetic sources, invokes the REAL installed CLI without network/socket,
4
+ and retains successes AND expected refusals. It does not repair the exporter,
5
+ render figures, manufacture transport archives, or mount a teaching manual.
6
+ Native computation locks below are explicitly synthetic check fixtures, not
7
+ evidence that R/Python rendering took place. Itemarium checking is optional and
8
+ uses its read-only source tree with a separately prepared Python interpreter.
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import argparse
13
+ import base64
14
+ import hashlib
15
+ import importlib.util
16
+ import io
17
+ import json
18
+ import os
19
+ from pathlib import Path
20
+ import subprocess
21
+ import sys
22
+ import tarfile
23
+ import uuid
24
+
25
+ ROOT = Path(__file__).resolve().parent.parent
26
+ IMAGE = "sha256:e0502369c85c59f9e512fbd9fdb17dbc128845d057a1423375425e68ce2e79a2"
27
+ OLD_IMAGE = "sha256:ea14b1224382e47031b979d294f970b66773155584ac8f9eb94a33dc6421b0c9"
28
+ SOURCE = "54afe7588c36e959a7eb75d5f52763a7456108d7"
29
+ PRODUCER = "sha256:a678d2b2649e3479d0049ca0ca8a500aba5bb3f0a59b070cd7aaf5c0c6b466d0"
30
+ SVG = '<svg xmlns="http://www.w3.org/2000/svg" width="30" height="20"><rect width="30" height="20" fill="white"/><text x="1" y="12">Fixture</text></svg>'
31
+
32
+
33
+ def sha(data):
34
+ return hashlib.sha256(data).hexdigest()
35
+
36
+
37
+ def save(path, value):
38
+ with path.open("x", encoding="utf-8") as stream:
39
+ json.dump(value, stream, indent=2, sort_keys=True, ensure_ascii=False)
40
+ stream.write("\n")
41
+
42
+
43
+ def write(root, name, data):
44
+ path = root / name
45
+ path.parent.mkdir(parents=True, exist_ok=True)
46
+ with path.open("xb") as stream:
47
+ stream.write(data.encode() if isinstance(data, str) else data)
48
+
49
+
50
+ def command(argv, *, cwd=None, expected=0, env=None):
51
+ result = subprocess.run(argv, cwd=cwd, env=env, capture_output=True, text=True, timeout=180)
52
+ if expected is not None and result.returncode != expected:
53
+ raise RuntimeError(f"Command failed: {argv}: {result.returncode}\n{result.stderr[-4000:]}")
54
+ return result
55
+
56
+
57
+ def cases(root):
58
+ from PIL import Image
59
+ import yaml
60
+ png = io.BytesIO()
61
+ Image.new("RGB", (2, 2), "navy").save(png, format="PNG")
62
+ encoded = base64.b64encode(png.getvalue()).decode()
63
+ rows = [
64
+ {"id": "baseline", "body": 'Text.\n\n![Alt](assets/img/plain.svg "Caption")\n', "ok": True},
65
+ {"id": "missing-id", "no_id": True, "error": "unaltraweb.manual.id"},
66
+ {"id": "missing-figure", "body": '![Alt](assets/img/missing.svg "Caption")\n', "error": "Missing editorial input"},
67
+ {"id": "public-input", "compute": "assets/data/input.gpkg", "ok": True},
68
+ {"id": "private-input", "compute": "_practices/fixture/data/input.gpkg", "error": "outside the public manual source roots"},
69
+ {"id": "private-input-other-chapter", "compute": "_practices/fixture/data/input.gpkg", "unreferenced": True,
70
+ "chapter": "first", "error": "outside the public manual source roots"},
71
+ {"id": "changed-public-input", "compute": "assets/data/input.gpkg", "changed_input": True,
72
+ "error": "source_or_environment_changed"},
73
+ {"id": "private-image", "body": '![Alt](_practices/fixture/private.png "Private")\n',
74
+ "extra": {"_practices/fixture/private.png": png.getvalue()}, "error": "outside the public manual source roots"},
75
+ {"id": "components", "body": '''{% include note.md %}
76
+
77
+ {% cite fixture %}
78
+
79
+ ::: table "Table caption"
80
+ | A | B |
81
+ | --- | --- |
82
+ | 1 | 2 |
83
+ :::
84
+
85
+ ::: subfigures a+b "Group caption"
86
+ ![A]({{ site.baseurl }}/assets/img/plain.svg "Panel A")
87
+ ![B](assets/img/plain.svg "Panel B")
88
+ :::
89
+ ''', "extra": {"_includes/note.md": "Term\n: Exact definition.\n\n>>>> Warning text.\n",
90
+ "_bibliography/manual.bib": '@book{fixture, title={Fixture reference}, author={Author, A}, year={2026}}'}, "ok": True},
91
+ {"id": "liquid-expression", "body": "Title: {{ page.title }}\n", "error": "unresolved Liquid/include"},
92
+ {"id": "include-parameters", "body": '{% include note.md label="Example" %}\n',
93
+ "extra": {"_includes/note.md": "A note."}, "error": "without parameters"},
94
+ {"id": "html-caption-code", "body": '<img src="assets/img/plain.svg" alt="Alt `code`" title="Caption `literal`">\n',
95
+ "ok": True, "expected_caption": "Caption `literal`"},
96
+ {"id": "embedded-raster", "body": '![Raster](assets/img/embedded.svg "Raster")\n',
97
+ "image": "assets/img/embedded.svg", "extra": {"assets/img/embedded.svg": '<svg xmlns="http://www.w3.org/2000/svg"><image href="data:image/png;base64,' + encoded + '" width="2" height="2"/></svg>'},
98
+ "error": "cannot receive SVG <image>"},
99
+ {"id": "external-raster", "body": '![Raster](assets/img/external.svg "Raster")\n',
100
+ "image": "assets/img/external.svg", "extra": {"assets/img/external.svg": '<svg xmlns="http://www.w3.org/2000/svg"><image href="https://example.invalid/image.png"/></svg>'},
101
+ "error": "cannot receive SVG <image>"},
102
+ {"id": "qgis-vector", "body": '![Map](assets/img/map.svg "Map")\n', "image": "assets/img/map.svg",
103
+ "extra": {"assets/img/map.svg": SVG.replace("<rect", '<metadata>{"producer":"unaltracaptura-qgis"}</metadata><rect')},
104
+ "error": "QGIS capture handoff is not supported"},
105
+ {"id": "qgis-untyped", "body": '![QGIS result](assets/img/map.svg "Authored static bytes")\n',
106
+ "image": "assets/img/map.svg", "extra": {"assets/img/map.svg": SVG}, "ok": True},
107
+ ]
108
+ for row in rows:
109
+ case = root / row["id"]
110
+ case.mkdir()
111
+ config = {"theme": "unaltraweb", "title": "Synthetic review", "default_lang": "ca", "languages": ["ca"],
112
+ "exclude": ["tmp/", "_practices/"], "unaltraweb": {"site_profile": "unaltremanual", "manual": {"id": "review-121"}}}
113
+ if row.get("no_id"):
114
+ del config["unaltraweb"]["manual"]["id"]
115
+ write(case, "_config.yml", yaml.safe_dump(config))
116
+ write(case, ".gitignore", "/tmp/\n")
117
+ write(case, "assets/img/plain.svg", SVG)
118
+ body = row.get("body", 'Text.\n\n![Alt](assets/img/plain.svg "Caption")\n')
119
+ if row.get("compute"):
120
+ write(case, ".unaltraweb/computations.yml", "version: 1\nsource_roots: [assets/compute]\n")
121
+ write(case, row["compute"], b"OPAQUE SYNTHETIC FINGERPRINT INPUT; NOT A GIS VALIDITY TEST\n")
122
+ front = {"title": "Computed fixture", "lang": "ca", "ref": "computed-fixture", "unaltraweb_compute": {
123
+ "engine": "python", "mode": "figure", "inputs": [row["compute"]], "outputs": ["assets/img/result.svg"]}}
124
+ write(case, "assets/compute/figure.qmd", "---\n" + yaml.safe_dump(front) + "---\nSynthetic source; no computation execution.\n")
125
+ write(case, "assets/img/result.svg", SVG)
126
+ if not row.get("unreferenced"):
127
+ body = '![Computed](assets/compute/figure.qmd "Computed fixture")\n'
128
+ write(case, "_chapters/ca/first.md", "---\ntitle: First\nlang: ca\nref: first\n---\n" + body)
129
+ for name, data in row.pop("extra", {}).items():
130
+ write(case, name, data)
131
+ command(["git", "init", "-b", "fixture"], cwd=case)
132
+ paths = [p.relative_to(case).as_posix() for p in case.rglob("*") if p.is_file() and ".git" not in p.parts]
133
+ command(["git", "add", "--", *paths], cwd=case)
134
+ command(["git", "-c", "user.name=Fixture", "-c", "user.email=fixture@example.invalid", "commit", "-m", "Synthetic review sources"], cwd=case)
135
+ save(root / "cases.json", rows)
136
+
137
+
138
+ def worker(root):
139
+ """Executed in the exact published image. No exporter function is replaced."""
140
+ from unaltraweb_mcp import __version__
141
+ from unaltraweb_mcp.manual_context import producer_identity
142
+ assert root == Path("/cases") and os.environ.get("UW_REVIEW_WORKER") == "1"
143
+ identity = producer_identity(Path("/opt/unaltraweb"))
144
+ assert __version__ == "0.8.0" and identity["revision"] == PRODUCER
145
+ rows = json.loads((root / "cases.json").read_text())
146
+ results = []
147
+ for row in rows:
148
+ case = root / row["id"]
149
+ native_status = None
150
+ if row.get("compute"):
151
+ spec = importlib.util.spec_from_file_location("review_native_compute", "/opt/unaltraweb/scripts/computations/render.py")
152
+ native = importlib.util.module_from_spec(spec)
153
+ spec.loader.exec_module(native)
154
+ config, config_path = native.load_config(case)
155
+ record, = native.discover_sources(case, config)
156
+ fingerprint, dependencies, image = native.fingerprint(case, config, config_path, record)
157
+ saved = {"fingerprint": fingerprint, "dependencies": dependencies, "image": image,
158
+ "image_identity": native.inspect_image(image["image"]),
159
+ "outputs": [{"path": p, **native.file_signature(case / p)} for p in record["output_paths"]]}
160
+ save(case / ".unaltraweb/computations.lock.json", {"version": 1, "records": {record["source_path"]: saved}})
161
+ native_status = native.status(case)
162
+ assert native_status["ok"], native_status
163
+ if row.get("changed_input"):
164
+ with (case / row["compute"]).open("ab") as stream:
165
+ stream.write(b"CHANGED INPUT\n")
166
+ before = {p.relative_to(case).as_posix(): sha(p.read_bytes()) for p in case.rglob("*")
167
+ if p.is_file() and ".git" not in p.parts and "tmp" not in p.relative_to(case).parts}
168
+ argv = [sys.executable, "-I", "-B", "-m", "unaltraweb_mcp.cli", "--project", str(case), "mcp",
169
+ "manual-context-export", "--name", "review", "--language", "ca"]
170
+ if row.get("chapter"):
171
+ argv += ["--chapter", row["chapter"]]
172
+ ran = command(argv, expected=None)
173
+ result = json.loads(ran.stdout)
174
+ expected = (ran.returncode == 0 and result.get("ok") is True) if row.get("ok") else (
175
+ ran.returncode == 1 and result.get("ok") is False and row["error"] in result.get("error", ""))
176
+ item = {"id": row["id"], "expected_behavior_observed": expected, "command": argv, "exit_code": ran.returncode,
177
+ "result": result, "stderr": ran.stderr, "source_hashes": before,
178
+ "synthetic_native_status": native_status, "renderer_executed": False}
179
+ archive = case / "tmp/manual-context/review/manual-context.tar.gz"
180
+ item["archive_created"] = archive.exists()
181
+ if result.get("ok"):
182
+ assert archive.is_file() and sha(archive.read_bytes()) == result["sha256"]
183
+ with tarfile.open(archive) as content:
184
+ members = {m.name: content.extractfile(m).read() for m in content.getmembers()}
185
+ manifest = json.loads(members.pop("manual-context.json"))
186
+ assert set(manifest["files"]) == set(members)
187
+ assert all(entry["sha256"] == sha(members[p]) and entry["size_bytes"] == len(members[p]) for p, entry in manifest["files"].items())
188
+ item["manifest"] = manifest
189
+ if row.get("expected_caption"):
190
+ item["expected_caption"] = row["expected_caption"]
191
+ item["caption_preserved"] = manifest["figures"][0]["caption"] == row["expected_caption"]
192
+ else:
193
+ assert not archive.exists(), "A refusal left a transport package"
194
+ assert all(sha((case / p).read_bytes()) == value for p, value in before.items())
195
+ save(root / (row["id"] + ".result.json"), item)
196
+ results.append(item)
197
+ save(root / "producer-review.json", {"ok": all(row["expected_behavior_observed"] for row in results),
198
+ "purpose": "Version-bound baseline review, not acceptance of known defects", "producer": identity,
199
+ "synthetic_only": True, "real_manual_mounted": False, "renderer_executed": False, "cases": results})
200
+
201
+
202
+ RECEIVER = '''import json, sys
203
+ from pathlib import Path
204
+ import itemarium
205
+ from itemarium.app.media import read_image
206
+ from itemarium.app.manual_package import check_package
207
+ from itemarium.runtime_identity import source_sha256
208
+ root = Path(sys.argv[1]); engine = Path(itemarium.__file__).parent
209
+ before = source_sha256(engine); results = []
210
+ for case in json.loads((root/'cases.json').read_text()):
211
+ result = json.loads((root/(case['id']+'.result.json')).read_text())['result']
212
+ row = {'id':case['id']}
213
+ if result.get('ok'):
214
+ row['package_check'] = check_package(package_path=root/case['id']/result['path'], sha256=result['sha256'])
215
+ if case.get('image'):
216
+ try:
217
+ read_image(root/case['id']/case['image']); row['image_check'] = {'ok':True}
218
+ except ValueError as error:
219
+ row['image_check'] = {'ok':False, 'error':str(error)}
220
+ results.append(row)
221
+ assert source_sha256(engine) == before
222
+ report = {'engine_version':itemarium.__version__, 'engine_source_sha256':before, 'cases':results,
223
+ 'imports_performed':False, 'manufacturer_of_alternate_packages':False,
224
+ 'image_checks_are_member_validation_only':True}
225
+ with (root/'receiver-review.json').open('x') as output:
226
+ json.dump(report, output, sort_keys=True, indent=2); output.write('\\n')
227
+ '''
228
+
229
+
230
+ def main():
231
+ parser = argparse.ArgumentParser(description=__doc__)
232
+ parser.add_argument("--output", type=Path)
233
+ parser.add_argument("--receiver-python", type=Path)
234
+ parser.add_argument("--receiver-source", type=Path)
235
+ parser.add_argument("--worker", type=Path, help=argparse.SUPPRESS)
236
+ args = parser.parse_args()
237
+ if args.worker:
238
+ worker(args.worker)
239
+ return
240
+ if not args.output or bool(args.receiver_python) != bool(args.receiver_source):
241
+ parser.error("Supply --output; pair --receiver-python with --receiver-source")
242
+ if not (ROOT / "mcp-factory.yml").is_file():
243
+ parser.error("Run this review harness from the unaltraweb source checkout")
244
+ output = args.output.resolve()
245
+ if output != args.output or not output.is_relative_to(ROOT / "tmp") or not output.parent.is_dir():
246
+ parser.error("Use a new absolute output below this checkout's tmp, with an existing parent")
247
+ observed, = json.loads(command(["docker", "image", "inspect", IMAGE]).stdout)
248
+ assert observed["Id"] == IMAGE and observed["Config"]["Labels"]["org.opencontainers.image.revision"] == SOURCE
249
+ output.mkdir(mode=0o700)
250
+ cases(output)
251
+ name = "unaltraweb-review121-" + uuid.uuid4().hex
252
+ argv = ["docker", "run", "--rm", "--pull", "never", "--name", name, "--network", "none", "--read-only",
253
+ "--user", f"{os.getuid()}:{os.getgid()}", "--cpus", "2", "--memory", "2g", "--pids-limit", "256",
254
+ "--cap-drop", "ALL", "--security-opt", "no-new-privileges", "--tmpfs", "/tmp:rw,nosuid,nodev,size=268435456",
255
+ "-e", "HOME=/tmp", "-e", "PYTHONDONTWRITEBYTECODE=1", "-e", "UW_REVIEW_WORKER=1",
256
+ "--mount", f"type=bind,source={output},target=/cases",
257
+ "--mount", f"type=bind,source={Path(__file__).resolve()},target=/review.py,readonly",
258
+ "--entrypoint", "python3", IMAGE, "-I", "-B", "/review.py", "--worker", "/cases"]
259
+ for path in (output, Path(__file__).resolve()):
260
+ if any(c in str(path) for c in ",\r\n"):
261
+ raise ValueError("Review mount path needs a simple literal spelling")
262
+ ran = command(argv, expected=None)
263
+ save(output / "execution.json", {"command": argv, "exit_code": ran.returncode, "stdout": ran.stdout, "stderr": ran.stderr})
264
+ command(["docker", "info", "--format", "{{.ID}}"])
265
+ absent = command(["docker", "container", "inspect", name], expected=None)
266
+ assert absent.returncode == 1 and "No such" in absent.stderr, "Exact review container absence not established"
267
+ if ran.returncode:
268
+ raise RuntimeError(ran.stderr[-6000:])
269
+ report = json.loads((output / "producer-review.json").read_text())
270
+ assert report["ok"], "A scenario differed from the recorded 0.8.0 behavior; inspect retained results"
271
+ old, = json.loads(command(["docker", "image", "inspect", OLD_IMAGE]).stdout)
272
+ assert old["Id"] == OLD_IMAGE
273
+ old_result = command(["docker", "run", "--rm", "--pull", "never", "--network", "none", "--read-only",
274
+ "--entrypoint", "unaltraweb-mcp", OLD_IMAGE, "mcp", "manual-context-export", "--name", "probe"], expected=None)
275
+ assert old_result.returncode == 2 and "invalid choice" in old_result.stderr
276
+ save(output / "old-runtime.json", {"image_id": OLD_IMAGE, "exit_code": old_result.returncode, "stderr": old_result.stderr})
277
+ if args.receiver_python:
278
+ environment = {**os.environ, "PYTHONPATH": str(args.receiver_source.resolve()), "PYTHONDONTWRITEBYTECODE": "1"}
279
+ # Keep a venv interpreter's spelling: resolving its python symlink would
280
+ # silently select the system interpreter and lose the prepared dependencies.
281
+ command([str(args.receiver_python.absolute()), "-B", "-c", RECEIVER, str(output)], env=environment)
282
+ print(json.dumps({"ok": True, "output": str(output), "cases": len(report["cases"]),
283
+ "receipt_sha256": sha((output / "producer-review.json").read_bytes()), "exact_container_absent": True}, indent=2))
284
+
285
+
286
+ if __name__ == "__main__":
287
+ main()
@@ -0,0 +1,145 @@
1
+ """Retained fixture acceptance through the real producer MCP, never a fabricated tar.
2
+
3
+ Run Itemarium's independent conformance command on the returned paths/hashes.
4
+ This script itself has no Itemarium dependency and uses no teaching workspace.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import argparse
9
+ import asyncio
10
+ import hashlib
11
+ import json
12
+ import os
13
+ from pathlib import Path
14
+ import sys
15
+ import subprocess
16
+ import uuid
17
+ import tarfile
18
+
19
+ ROOT = Path(__file__).resolve().parents[1]
20
+ sys.path.insert(0, str(ROOT / "src"))
21
+ sys.path.insert(0, str(ROOT / "test"))
22
+ from test_manual_context import fixture, write, commit, computation_fixture # noqa: E402
23
+
24
+
25
+ def save(path, value):
26
+ with path.open("x") as stream:
27
+ json.dump(value, stream, ensure_ascii=False, sort_keys=True, indent=2)
28
+ stream.write("\n")
29
+
30
+
31
+ def verify_private_inputs_html(project, result):
32
+ with tarfile.open(project / result["path"]) as archive:
33
+ files = {entry.name: archive.extractfile(entry).read() for entry in archive.getmembers()}
34
+ manifest = json.loads(files["manual-context.json"])
35
+ assert manifest["figures"][0]["caption"] == "Caption `literal`"
36
+ assert manifest["figures"][0]["alt"] == "Alt `code`"
37
+ assert set(files) == {"manual-context.json", *manifest["files"]}
38
+ data = b"".join(files.values())
39
+ assert b"PRIVATE_CHECK_ONLY_SENTINEL" not in data and b"_practices/" not in data
40
+ assert b"UNALTRAWEBEXPORTPROTECTED" not in data
41
+
42
+
43
+ async def export(project, name, image=""):
44
+ from mcp import ClientSession, StdioServerParameters
45
+ from mcp.client.stdio import stdio_client
46
+ def payload(result):
47
+ return result.structuredContent or json.loads("\n".join(row.text for row in result.content if hasattr(row, "text")))
48
+ parameters = StdioServerParameters(command=sys.executable,
49
+ args=["-B", "-m", "unaltraweb_mcp.cli", "mcp", "serve"],
50
+ env={"PATH": os.environ["PATH"], "HOME": str(project), "PYTHONPATH": str(ROOT / "src"),
51
+ "MCP_CONSUMER_WORKSPACE": str(project), "UNALTRAWEB_FACTORY_DIR": str(ROOT)})
52
+ container = ""
53
+ image_id = ""
54
+ if image:
55
+ from unaltraweb_mcp.docker_mount import docker_bind_mount
56
+ image_id = subprocess.run(["docker", "image", "inspect", image, "--format", "{{.Id}}"], check=True,
57
+ capture_output=True, text=True, timeout=20).stdout.strip()
58
+ container = "unaltraweb-context-121-" + uuid.uuid4().hex
59
+ parameters = StdioServerParameters(command="docker", args=["run", "--rm", "-i", "--pull", "never",
60
+ "--name", container, "--network", "none", "--read-only", "--user", f"{os.getuid()}:{os.getgid()}",
61
+ "--cpus", "2", "--memory", "2g", "--pids-limit", "256", "--tmpfs", "/tmp:rw,nosuid,nodev,size=268435456",
62
+ "--label", "io.context.mcp-factory=unaltraweb", "--label", "io.context.mcp-role=context-acceptance",
63
+ "--mount", docker_bind_mount(project, "/workspace"), "-e", "HOME=/tmp",
64
+ "-e", "UNALTRAWEB_DOCKER_ROOT=" + str(project), "-e", "UNALTRAWEB_MCP_IMAGE=" + image_id,
65
+ "--entrypoint", "unaltraweb-mcp", image_id, "--project", "/workspace", "mcp", "serve"])
66
+ async with stdio_client(parameters) as (incoming, outgoing):
67
+ async with ClientSession(incoming, outgoing) as client:
68
+ await client.initialize()
69
+ tools = await client.list_tools()
70
+ tool = next(row for row in tools.tools if row.name == "manual_context_export")
71
+ assert not {"root", "project", "workspace"} & tool.inputSchema["properties"].keys()
72
+ identity = payload(await client.call_tool("runtime_identity", {}))
73
+ result = payload(await client.call_tool("manual_context_export", {"name": name, "language": "ca"}))
74
+ assert result["ok"], result
75
+ again = payload(await client.call_tool("manual_context_export", {"name": name, "language": "ca"}))
76
+ assert not again["ok"] and "already exists" in again["error"], again
77
+ await client.call_tool("runtime_drain", {"confirm": True})
78
+ result["absolute_path"] = str(project / result["path"])
79
+ assert hashlib.sha256(Path(result["absolute_path"]).read_bytes()).hexdigest() == result["sha256"]
80
+ observed = {"identity": identity, "export": result, "create_only_refusal": again}
81
+ if image:
82
+ assert identity["package"]["mode"] == "installed" and not identity["package"]["drift"]
83
+ absent = subprocess.run(["docker", "container", "inspect", container], capture_output=True, timeout=20)
84
+ assert absent.returncode != 0, "Fixture MCP container remains after stdio close"
85
+ observed.update(image_id=image_id, exact_container_absent=True)
86
+ return observed
87
+
88
+
89
+ def main():
90
+ parser = argparse.ArgumentParser(description=__doc__)
91
+ parser.add_argument("--output", type=Path, required=True)
92
+ parser.add_argument("--single", action="store_true")
93
+ parser.add_argument("--image", default="", help="Already-built MCP image; pull-never, network-disabled fixture stdio")
94
+ parser.add_argument("--private-inputs-html", action="store_true", help="Exercise P1/P2 with explicitly synthetic native computation records")
95
+ args = parser.parse_args()
96
+ output = args.output.resolve()
97
+ if output != args.output or not output.is_relative_to(ROOT / "tmp") or not output.parent.is_dir():
98
+ raise ValueError("Use a new absolute output directory below the factory tmp with an existing parent")
99
+ output.mkdir(mode=0o700)
100
+ project = output / "manual-fixture"
101
+ project.mkdir()
102
+ fixture(project)
103
+ if args.private_inputs_html:
104
+ write(project, "_practices/fixture/data/input.csv", "PRIVATE_CHECK_ONLY_SENTINEL,1\n")
105
+ write(project, "_practices/fixture/data/parameters.yml", "defaults: &defaults {value: PRIVATE_CHECK_ONLY_SENTINEL}\nselected: *defaults\n")
106
+ computation_fixture(project, inputs=["_practices/fixture/data/input.csv", "_practices/fixture/data/parameters.yml"])
107
+ chapter = project / "_chapters/ca/first.md"
108
+ original = chapter.read_text()
109
+ original = original.replace('![Alt](assets/img/map.png "Mapa de prova"){: data-caption-source="Autoria sintètica"}',
110
+ '<img src="assets/img/map.png" alt="Alt `code`" title="Caption `literal`">')
111
+ write(project, "_chapters/ca/first.md", original)
112
+ commit(project, ["_practices/fixture/data/input.csv", "_practices/fixture/data/parameters.yml", ".unaltraweb/computations.yml", ".unaltraweb/computations.lock.json",
113
+ "assets/compute/figure.qmd", "assets/img/figure.svg", "_chapters/ca/first.md"], "P1/P2 synthetic native check inputs")
114
+ first = asyncio.run(export(project, "v1", args.image))
115
+ if args.private_inputs_html:
116
+ verify_private_inputs_html(project, first["export"])
117
+ save(output / "producer-v1.json", first)
118
+ command = [sys.executable, "-B", "-m", "unaltraweb_mcp.cli", "--project", str(project), "mcp", "manual-context-export",
119
+ "--name", "cli-block", "--language", "ca", "--chapter", "first", "--section", "first#method"]
120
+ completed = subprocess.run(command, env={**os.environ, "PYTHONPATH": str(ROOT / "src"), "UNALTRAWEB_FACTORY_DIR": str(ROOT)},
121
+ check=True, capture_output=True, text=True, timeout=120)
122
+ cli = json.loads(completed.stdout)
123
+ assert cli["ok"] and cli["sections"] == 1 and cli["figures"] == 1, cli
124
+ save(output / "producer-cli.json", {"command": command, "result": cli})
125
+ result = {"ok": True, "synthetic_manual": True, "producer_export_invoked": True, "transport": "mcp-stdio",
126
+ "first": first["export"], "cli_verified": True, "questions_generated": 0,
127
+ "private_inputs_html": args.private_inputs_html, "computation_render_executed": False}
128
+ if not args.single:
129
+ original = (project / "_chapters/ca/first.md").read_text()
130
+ write(project, "_chapters/ca/first.md", original + "\nSegona versió: text afegit.\n")
131
+ write(project, "_chapters/ca/second.md", "---\ntitle: Segon\nref: second\nlang: ca\nweight: 20\n---\nCapítol afegit en la segona versió.\n")
132
+ commit(project, ["_chapters/ca/first.md", "_chapters/ca/second.md"], "Second fixture revision")
133
+ second = asyncio.run(export(project, "v2", args.image))
134
+ if args.private_inputs_html:
135
+ verify_private_inputs_html(project, second["export"])
136
+ save(output / "producer-v2.json", second)
137
+ result["next"] = second["export"]
138
+ assert first["export"]["manual"]["id"] == second["export"]["manual"]["id"]
139
+ assert first["export"]["manual"]["revision"] != second["export"]["manual"]["revision"]
140
+ save(output / "producer-receipt.json", result)
141
+ print(json.dumps(result, indent=2, ensure_ascii=False))
142
+
143
+
144
+ if __name__ == "__main__":
145
+ main()
@@ -15,7 +15,7 @@ Managed mode requires an immutable reference or an expected full image ID.
15
15
  USAGE
16
16
  }
17
17
 
18
- image="${UNALTRAWEB_MCP_IMAGE:-ghcr.io/dosquartsdedocs/unaltraweb-mcp@sha256:8183e7117f5902fc0ad4d930b757d874b2da1f93d5ef4db102ae4c1b03e357a3}"
18
+ image="${UNALTRAWEB_MCP_IMAGE:-ghcr.io/dosquartsdedocs/unaltraweb-mcp@sha256:8b41abacad9ca2894755db7f3587a6b47c8fa8f536d88a151bdfc4ae53ba7f08}"
19
19
  project="${MCP_CONSUMER_WORKSPACE:-${UNALTRAWEB_PROJECT:-}}"
20
20
  operation=serve
21
21
  expected_image="${UNALTRAWEB_EXPECTED_IMAGE_ID:-}"
@@ -2,8 +2,8 @@
2
2
  "$schema": "component-contract.schema.json",
3
3
  "schema_version": 1,
4
4
  "release": {
5
- "version": "0.7.4",
6
- "tag": "v0.7.4"
5
+ "version": "0.8.1",
6
+ "tag": "v0.8.1"
7
7
  },
8
8
  "consumer_integration": {
9
9
  "schema_version": 1,
@@ -55,17 +55,17 @@
55
55
  "package_only_commands": ["doctor", "import-calibre", "new-web", "version"],
56
56
  "factory_required_commands": ["factory-dir"],
57
57
  "package_only_mcp": ["artifact-import-check", "import-artifact-bundle", "bibliography-add-entry", "bibliography-inventory", "build-health", "content-approval-inventory", "content-freshness-check", "content-inventory", "detect-site", "editorial-policy", "editorial-publication-check", "editorial-review-prepare", "editorial-review-record", "editorial-review-resolve", "editorial-status", "html-audit", "http-check", "image-background-check", "initialize-site", "language-policy", "list-tools", "manual-authoring-capabilities", "manual-editorial-quality-check", "manual-pdf-preview-clean", "manual-source-quality-check", "new-web", "preview-start", "preview-status", "preview-stop", "profile-check", "profile-prune", "profile-prune-plan", "prose-check", "scaffold-sync", "site-context", "site-doctor", "site-source-delete", "site-source-read", "site-source-write", "starter-templates", "translation-plan"],
58
- "factory_required_mcp": ["bibliometrics-check", "bibliometrics-fetch-scimago", "bibliometrics-update", "build-site", "manual-computation-check", "manual-computation-render", "manual-computation-render-figures", "manual-computation-status", "manual-pdf-build", "manual-pdf-preview-prepare", "manual-pdf-publish", "manual-pdf-status", "manual-practice-compose", "manual-practice-pdf-build", "manual-release-check", "manual-release-prepare", "manual-release-status", "prompts", "serve", "site-check", "web-capture-check", "web-capture-render", "web-capture-status"],
58
+ "factory_required_mcp": ["bibliometrics-check", "bibliometrics-fetch-scimago", "bibliometrics-update", "build-site", "manual-computation-check", "manual-computation-render", "manual-computation-render-figures", "manual-computation-status", "manual-context-export", "manual-pdf-build", "manual-pdf-preview-prepare", "manual-pdf-publish", "manual-pdf-status", "manual-practice-compose", "manual-practice-pdf-build", "manual-release-check", "manual-release-prepare", "manual-release-status", "prompts", "serve", "site-check", "web-capture-check", "web-capture-render", "web-capture-status"],
59
59
  "not_bundled": ["gem", "runtime", "mcp", "compute_python", "compute_r", "web_capture", "manual_pdf", "diavisuals", "vegavisuals", "qgis_practice_verifier"]
60
60
  },
61
61
  "components": {
62
62
  "gem": {
63
63
  "kind": "gem",
64
64
  "name": "unaltraweb",
65
- "version": "0.7.4",
66
- "release": "v0.7.4",
65
+ "version": "0.8.1",
66
+ "release": "v0.8.1",
67
67
  "release_status": "ready",
68
- "reference": "unaltraweb (= 0.7.4)",
68
+ "reference": "unaltraweb (= 0.8.1)",
69
69
  "repository": "https://github.com/dosquartsdedocs/unaltraweb",
70
70
  "included_in_wheel": false,
71
71
  "features": ["jekyll-core", "site-build"]
@@ -73,10 +73,10 @@
73
73
  "wheel": {
74
74
  "kind": "python-wheel",
75
75
  "name": "unaltraweb-mcp",
76
- "version": "0.7.4",
77
- "release": "v0.7.4",
76
+ "version": "0.8.1",
77
+ "release": "v0.8.1",
78
78
  "release_status": "ready",
79
- "reference": "unaltraweb-mcp==0.7.4",
79
+ "reference": "unaltraweb-mcp==0.8.1",
80
80
  "repository": "https://github.com/dosquartsdedocs/unaltraweb",
81
81
  "included_in_wheel": true,
82
82
  "features": ["calibre-import", "doctor", "new-web", "offline-inspection", "mcp-control-plane", "source-ci"]
@@ -84,10 +84,10 @@
84
84
  "runtime": {
85
85
  "kind": "container",
86
86
  "name": "unaltraweb runtime",
87
- "version": "0.7.4",
88
- "release": "v0.7.4",
87
+ "version": "0.8.1",
88
+ "release": "v0.8.1",
89
89
  "release_status": "ready",
90
- "reference": "ghcr.io/dosquartsdedocs/unaltraweb:0.7.4",
90
+ "reference": "ghcr.io/dosquartsdedocs/unaltraweb:0.8.1",
91
91
  "image_repository": "ghcr.io/dosquartsdedocs/unaltraweb",
92
92
  "repository": "https://github.com/dosquartsdedocs/unaltraweb",
93
93
  "included_in_wheel": false,
@@ -96,10 +96,10 @@
96
96
  "mcp": {
97
97
  "kind": "container",
98
98
  "name": "unaltraweb MCP runtime",
99
- "version": "0.7.4",
100
- "release": "v0.7.4",
99
+ "version": "0.8.1",
100
+ "release": "v0.8.1",
101
101
  "release_status": "ready",
102
- "reference": "ghcr.io/dosquartsdedocs/unaltraweb-mcp:0.7.4",
102
+ "reference": "ghcr.io/dosquartsdedocs/unaltraweb-mcp:0.8.1",
103
103
  "image_repository": "ghcr.io/dosquartsdedocs/unaltraweb-mcp",
104
104
  "repository": "https://github.com/dosquartsdedocs/unaltraweb",
105
105
  "included_in_wheel": false,
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: unaltraweb
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.7.4
4
+ version: 0.8.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - dosquartsdedocs
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-10-26 00:00:00.000000000 Z
11
+ date: 2026-11-04 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: classifier-reborn
@@ -783,6 +783,7 @@ files:
783
783
  - docs/_documentation/en/44-editorial-review.md
784
784
  - docs/_documentation/en/45-retained-documents.md
785
785
  - docs/_documentation/en/46-live-runtime.md
786
+ - docs/_documentation/en/47-manual-context.md
786
787
  - docs/_documentation/en/47-private-practice-pdfs.md
787
788
  - docs/_documentation/en/50-faq.md
788
789
  - docs/_pages/ca/index.md
@@ -806,6 +807,9 @@ files:
806
807
  - docs/agents/deploy-provenance-86.md
807
808
  - docs/agents/deployment-integration-0.7.1.md
808
809
  - docs/agents/manual-authoring-components.md
810
+ - docs/agents/manual-context-121.md
811
+ - docs/agents/manual-context-fix-121.md
812
+ - docs/agents/manual-context-pilot-review-121.md
809
813
  - docs/agents/mcp-contract.md
810
814
  - docs/agents/owner-closeout-76.md
811
815
  - docs/agents/owner-closeout-80.md
@@ -857,7 +861,9 @@ files:
857
861
  - scripts/manual/verify_release_assets.py
858
862
  - scripts/prepare_practice_candidate.py
859
863
  - scripts/prepare_practice_editions_candidate.py
864
+ - scripts/review_manual_context_pilots.py
860
865
  - scripts/test_gem_build.py
866
+ - scripts/test_manual_context_export.py
861
867
  - scripts/test_reproducible_jekyll_build.py
862
868
  - scripts/test_scaffold_release.py
863
869
  - scripts/test_wheel_install.py