lockwire 0.2.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.
- lockwire/__init__.py +9 -0
- lockwire/__main__.py +9 -0
- lockwire/advice.py +119 -0
- lockwire/api.py +1216 -0
- lockwire/api_payload.py +985 -0
- lockwire/bench.py +182 -0
- lockwire/blame.py +100 -0
- lockwire/catalog.py +100 -0
- lockwire/catalog_seed.yaml +545 -0
- lockwire/checks.py +3483 -0
- lockwire/cli.py +1799 -0
- lockwire/cli_line.py +73 -0
- lockwire/constraints.py +313 -0
- lockwire/copy.py +302 -0
- lockwire/describe.py +682 -0
- lockwire/edit.py +2098 -0
- lockwire/emit_ts.py +150 -0
- lockwire/export_mhs.py +115 -0
- lockwire/factdiff.py +254 -0
- lockwire/forces.py +334 -0
- lockwire/gitref.py +188 -0
- lockwire/hw.py +388 -0
- lockwire/mcp_server.py +276 -0
- lockwire/mechanism.py +379 -0
- lockwire/mesh_dae.py +555 -0
- lockwire/mesh_simple.py +198 -0
- lockwire/page.py +1485 -0
- lockwire/plan.py +849 -0
- lockwire/plan_payload.py +151 -0
- lockwire/plugins.py +70 -0
- lockwire/report.py +848 -0
- lockwire/report_html.py +707 -0
- lockwire/report_json.py +168 -0
- lockwire/review_draft.py +315 -0
- lockwire/scan.py +732 -0
- lockwire/scene.py +535 -0
- lockwire/scene_import.py +309 -0
- lockwire/scene_urdf.py +750 -0
- lockwire/scene_view.py +376 -0
- lockwire/schema.py +3616 -0
- lockwire/sdf.py +240 -0
- lockwire/serve.py +1256 -0
- lockwire/start_operation.py +92 -0
- lockwire/statics.py +77 -0
- lockwire/units.py +115 -0
- lockwire/urdf.py +348 -0
- lockwire/vcs.py +402 -0
- lockwire/whatif.py +161 -0
- lockwire-0.2.0.dist-info/METADATA +192 -0
- lockwire-0.2.0.dist-info/RECORD +52 -0
- lockwire-0.2.0.dist-info/WHEEL +4 -0
- lockwire-0.2.0.dist-info/entry_points.txt +3 -0
lockwire/cli.py
ADDED
|
@@ -0,0 +1,1799 @@
|
|
|
1
|
+
"""lockwire CLI: start, gui, init, check, describe, baseline, scan, preflight, hw, mcp, serve.
|
|
2
|
+
|
|
3
|
+
Every gated command's exit-code line is rendered from the one table in
|
|
4
|
+
``report.EXIT_CODE_TABLE`` (see `report.exit_code_help`), not copied by hand —
|
|
5
|
+
``check``/``plan``/``preflight``/``declare``/``health``/``hw`` all agree that a
|
|
6
|
+
bad or missing contract exits 2 (README.md:162-166 has the full map).
|
|
7
|
+
``hw`` records a W0 declaration and routes impact; same exit shape as ``declare``.
|
|
8
|
+
``gui``/``start`` are viewers, not gates: they exit 0 whenever the page was
|
|
9
|
+
written, so opening it in the browser never reports a verdict of its own.
|
|
10
|
+
``mcp`` serves lockwire's checks to an agent over MCP; the ``mcp`` package is an
|
|
11
|
+
optional extra, imported lazily so a plain install never needs it.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import datetime as dt
|
|
15
|
+
import io
|
|
16
|
+
import json
|
|
17
|
+
import sys
|
|
18
|
+
import xml.etree.ElementTree as ET
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Any
|
|
21
|
+
|
|
22
|
+
import typer
|
|
23
|
+
from rich.console import Console
|
|
24
|
+
from ruamel.yaml import YAML, YAMLError
|
|
25
|
+
from typer.core import TyperCommand
|
|
26
|
+
|
|
27
|
+
from lockwire import __version__, export_mhs, gitref
|
|
28
|
+
from lockwire import api, plugins
|
|
29
|
+
from lockwire.bench import DEFAULT_CORPUS_PATH, format_report, load_corpus, run_bench
|
|
30
|
+
from lockwire.checks import run_record_checks
|
|
31
|
+
from lockwire.cli_line import argv_from_context, cli_line as build_cli_line
|
|
32
|
+
from lockwire.describe import describe_all, describe_device, describe_system
|
|
33
|
+
from lockwire.edit import (
|
|
34
|
+
Change,
|
|
35
|
+
EditRefusal,
|
|
36
|
+
add_entry,
|
|
37
|
+
apply_changes,
|
|
38
|
+
apply_review,
|
|
39
|
+
contract_revision,
|
|
40
|
+
move as edit_move,
|
|
41
|
+
place as edit_place,
|
|
42
|
+
relative_source,
|
|
43
|
+
reparent as edit_reparent,
|
|
44
|
+
resolve_witness,
|
|
45
|
+
rotate as edit_rotate,
|
|
46
|
+
set_mount_shape as edit_mount_shape,
|
|
47
|
+
)
|
|
48
|
+
from lockwire.factdiff import fact_diff, load_contract_text, render_lines
|
|
49
|
+
from lockwire.hw import record_hardware_change
|
|
50
|
+
from lockwire.plan import load_plan, validate_plan
|
|
51
|
+
from lockwire.report import (
|
|
52
|
+
exit_code_help,
|
|
53
|
+
render,
|
|
54
|
+
)
|
|
55
|
+
from lockwire.report_html import write_html_report
|
|
56
|
+
from lockwire.scan import (
|
|
57
|
+
UnsupportedPlatformError,
|
|
58
|
+
scan_machine,
|
|
59
|
+
)
|
|
60
|
+
from lockwire.scene import SceneRefusal
|
|
61
|
+
from lockwire.scene_import import (
|
|
62
|
+
format_point,
|
|
63
|
+
SceneImportRefusal,
|
|
64
|
+
build_import,
|
|
65
|
+
write_and_apply_import,
|
|
66
|
+
write_change_file,
|
|
67
|
+
)
|
|
68
|
+
from lockwire.schema import (
|
|
69
|
+
ArtifactRef,
|
|
70
|
+
Contract,
|
|
71
|
+
RobotInfo,
|
|
72
|
+
UnsupportedSchemaVersion,
|
|
73
|
+
save_contract,
|
|
74
|
+
)
|
|
75
|
+
from lockwire.schema import load_contract as _load_contract_lib
|
|
76
|
+
from lockwire.sdf import parse_sdf
|
|
77
|
+
from lockwire.urdf import _refuse_doctype_entity, parse_urdf
|
|
78
|
+
|
|
79
|
+
app = typer.Typer(
|
|
80
|
+
name="lockwire",
|
|
81
|
+
help="The physical-assumption contract for robots: physics lockfile + preflight gate.",
|
|
82
|
+
no_args_is_help=True,
|
|
83
|
+
add_completion=False,
|
|
84
|
+
)
|
|
85
|
+
console = Console()
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
scene_app = typer.Typer(help="The scene door: read a 3D model of the robot.")
|
|
89
|
+
app.add_typer(scene_app, name="scene")
|
|
90
|
+
|
|
91
|
+
DEFAULT_CONTRACT = Path("lockwire.yaml")
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def load_contract(path: Path) -> Contract:
|
|
95
|
+
"""The door onto ``schema.load_contract`` for every command that loads one
|
|
96
|
+
itself.
|
|
97
|
+
|
|
98
|
+
Most contract-taking commands call this module-global name, so a contract
|
|
99
|
+
written by a newer lockwire is refused in exactly one place — the word
|
|
100
|
+
REFUSED and exit 2 — instead of each command's own ``except``
|
|
101
|
+
printing a plain "<cmd> failed:" line with whatever exit code it happened
|
|
102
|
+
to pick (`gui`, `start` and `report` picked 1).
|
|
103
|
+
|
|
104
|
+
The four that do not (`sample`, `declare`, `apply`, `add`) hand the path to
|
|
105
|
+
a library that loads it (``sample_local``, ``edit``); those libraries raise
|
|
106
|
+
the same typed ``UnsupportedSchemaVersion``, and ``_refused`` below renders
|
|
107
|
+
it with the same word and code. No list of doors is kept here:
|
|
108
|
+
``test_every_verdict_door_refuses_an_unsupported_schema_version``
|
|
109
|
+
enumerates them off the typer app, so a new door cannot go unnoticed.
|
|
110
|
+
|
|
111
|
+
``typer.Exit`` is a ``RuntimeError``, so the refusal passes straight
|
|
112
|
+
through the ``except (OSError, TypeError, ValueError, YAMLError)`` blocks
|
|
113
|
+
the commands wrap this call in. Every other failure is re-raised unchanged
|
|
114
|
+
for those blocks to handle as they always have.
|
|
115
|
+
"""
|
|
116
|
+
try:
|
|
117
|
+
return _load_contract_lib(path)
|
|
118
|
+
except UnsupportedSchemaVersion as error:
|
|
119
|
+
console.print(f"[bold red]REFUSED[/] — {error}")
|
|
120
|
+
raise typer.Exit(2) from error
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _refused(command: str, error: Exception) -> None:
|
|
124
|
+
"""Print one command failure line: REFUSED when the contract declares a
|
|
125
|
+
schema_version this lockwire cannot read, the command's own "<cmd> failed:"
|
|
126
|
+
line otherwise. For the commands that load their contract inside a library
|
|
127
|
+
(`sample`, `declare`, `apply`, `add`) rather than through ``load_contract``.
|
|
128
|
+
"""
|
|
129
|
+
if isinstance(error, UnsupportedSchemaVersion):
|
|
130
|
+
console.print(f"[bold red]REFUSED[/] — {error}")
|
|
131
|
+
else:
|
|
132
|
+
console.print(f"[red]{command} failed:[/] {error}")
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
# invoke_without_command is what lets `--version` run at all: without it Click fails on
|
|
136
|
+
# the missing subcommand while parsing, before this body is ever reached, so the flag
|
|
137
|
+
# advertised in --help exited 2 with "Missing command". `no_args_is_help` on the Typer
|
|
138
|
+
# above still handles a bare `lockwire`.
|
|
139
|
+
@app.callback(invoke_without_command=True)
|
|
140
|
+
def _version_callback(
|
|
141
|
+
version: bool = typer.Option(False, "--version", help="Print version and exit."),
|
|
142
|
+
) -> None:
|
|
143
|
+
if version:
|
|
144
|
+
console.print(f"lockwire {__version__}")
|
|
145
|
+
raise typer.Exit(0)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _write_document(out: Path | None, document: dict) -> None:
|
|
149
|
+
"""Write one library document to ``out``, or do nothing when --json was not given."""
|
|
150
|
+
if out is None:
|
|
151
|
+
return
|
|
152
|
+
out.write_text(json.dumps(document, indent=2) + "\n", encoding="utf-8")
|
|
153
|
+
console.print(f"[green]wrote {out}[/]")
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _frozen() -> bool:
|
|
157
|
+
"""True in the packaged binary (`packaging/lockwire.spec`), false from source.
|
|
158
|
+
|
|
159
|
+
PyInstaller sets `sys.frozen`; nothing else in this repo does. This is the one
|
|
160
|
+
frozen-mode guard — a later door that must refuse when packaged reuses it rather
|
|
161
|
+
than inventing a second way to ask the same question.
|
|
162
|
+
"""
|
|
163
|
+
return getattr(sys, "frozen", False)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _refuse_default_out_when_frozen(ctx: typer.Context, param: str, command: str) -> None:
|
|
167
|
+
"""In the packaged binary, refuse a page path the caller did not choose.
|
|
168
|
+
|
|
169
|
+
Frozen, this process is the desktop app's sidecar: its working directory is
|
|
170
|
+
wherever the app was launched from, and there is no browser for it to open the page
|
|
171
|
+
in. A defaulted relative `--out` would write the file somewhere the caller cannot
|
|
172
|
+
find and then say nothing. One sentence, exit 2, and the caller names the file.
|
|
173
|
+
"""
|
|
174
|
+
if not _frozen():
|
|
175
|
+
return
|
|
176
|
+
# By name, not by identity: typer and click each expose a `ParameterSource` enum and
|
|
177
|
+
# they are not the same class, so `is ParameterSource.DEFAULT` silently never matched.
|
|
178
|
+
source = ctx.get_parameter_source(param)
|
|
179
|
+
if source is not None and source.name != "DEFAULT":
|
|
180
|
+
return
|
|
181
|
+
console.print(
|
|
182
|
+
f"[red]{command} needs --out here:[/] this is the packaged sidecar, so there is no "
|
|
183
|
+
"browser to open and no obvious folder to write into — say where the file goes."
|
|
184
|
+
)
|
|
185
|
+
raise typer.Exit(2)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _file_kind_word(path: Path) -> str:
|
|
189
|
+
"""Name the file's root element (F3), not its extension: a `.sdf` some tool
|
|
190
|
+
wrote as `<robot>` (or vice versa) is described for what it actually is."""
|
|
191
|
+
raw = path.read_bytes()
|
|
192
|
+
_refuse_doctype_entity(raw, path) # before any parse touches the bytes
|
|
193
|
+
root = ET.fromstring(raw) # xacro refusal happens inside parse_urdf itself
|
|
194
|
+
return "SDF" if root.tag == "sdf" else "URDF"
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _parse_model_file(path: Path, contract: Path) -> Any:
|
|
198
|
+
"""`init`/`start` accept a URDF or an SDF — dispatch by root element (F3),
|
|
199
|
+
not only by extension: a `.sdf` some tool wrote as `<robot>` (or vice versa)
|
|
200
|
+
is read for what it actually is.
|
|
201
|
+
|
|
202
|
+
The model file's own path is the contract's first source, so it is stamped
|
|
203
|
+
relative to the contract, never as the absolute path the caller typed.
|
|
204
|
+
"""
|
|
205
|
+
model = parse_sdf(path) if _file_kind_word(path) == "SDF" else parse_urdf(path)
|
|
206
|
+
spelling = relative_source(contract, path)
|
|
207
|
+
return model.model_copy(
|
|
208
|
+
update={
|
|
209
|
+
"source": spelling,
|
|
210
|
+
"provenance": model.provenance.model_copy(update={"source": spelling}),
|
|
211
|
+
}
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _find_scene(contract_path: Path, scene_flag: Path | None) -> tuple[Path | None, bool]:
|
|
216
|
+
"""Return (path, discovered). ``discovered`` is True only for sidecar auto-pickup."""
|
|
217
|
+
if scene_flag is not None:
|
|
218
|
+
return scene_flag, False
|
|
219
|
+
for ext in (".glb", ".gltf", ".urdf", ".sdf"):
|
|
220
|
+
candidate = contract_path.with_suffix(ext)
|
|
221
|
+
if candidate.is_file():
|
|
222
|
+
return candidate, True
|
|
223
|
+
# `start` pins the model's meshes; a contract that carries them names its own scene.
|
|
224
|
+
try:
|
|
225
|
+
model = _load_contract_lib(contract_path).model
|
|
226
|
+
except Exception: # noqa: BLE001 - no readable contract means no scene to find
|
|
227
|
+
return None, False
|
|
228
|
+
if model is not None and model.external_assets:
|
|
229
|
+
declared = contract_path.parent / model.source
|
|
230
|
+
if declared.suffix.lower() in (".urdf", ".sdf") and declared.is_file():
|
|
231
|
+
return declared, True
|
|
232
|
+
return None, False
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def _load_scene_for_cli(
|
|
236
|
+
contract_path: Path,
|
|
237
|
+
scene_flag: Path | None,
|
|
238
|
+
*,
|
|
239
|
+
command: str,
|
|
240
|
+
package_roots: tuple[Path, ...] = (),
|
|
241
|
+
) -> tuple[Any, str | None]:
|
|
242
|
+
"""Load the scene named by --scene or discovered beside the contract, announcing it.
|
|
243
|
+
|
|
244
|
+
`lockwire check` never sees the scene; this is only for gui/report. A
|
|
245
|
+
``SceneRefusal`` is the tool-failure posture (exit 2), same as an unresolved xacro.
|
|
246
|
+
"""
|
|
247
|
+
scene_path, discovered = _find_scene(contract_path, scene_flag)
|
|
248
|
+
if scene_path is None:
|
|
249
|
+
return None, None
|
|
250
|
+
try:
|
|
251
|
+
loaded_scene, _ = api.load_scene(contract_path, scene_flag, package_roots)
|
|
252
|
+
except SceneRefusal as error:
|
|
253
|
+
console.print(f"[red]{error}[/]")
|
|
254
|
+
raise typer.Exit(2) from error
|
|
255
|
+
if discovered:
|
|
256
|
+
console.print(f"scene: {scene_path.name} (found beside the contract)")
|
|
257
|
+
source = f"{scene_path.name} (found beside the contract)"
|
|
258
|
+
else:
|
|
259
|
+
console.print(f"scene: {scene_path.name}")
|
|
260
|
+
source = f"{scene_path.name} (given)"
|
|
261
|
+
return loaded_scene, source
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
@app.command(
|
|
265
|
+
help="Start here: make a lockfile from your URDF.\n\n"
|
|
266
|
+
"A fresh lockfile knows the frame and nothing else; the app shows what to fill in "
|
|
267
|
+
"next.\n\n" + exit_code_help("start")
|
|
268
|
+
)
|
|
269
|
+
def start(
|
|
270
|
+
ctx: typer.Context,
|
|
271
|
+
urdf: Path = typer.Argument(..., exists=True, readable=True, help="Your robot's URDF or SDF."),
|
|
272
|
+
contract: Path = typer.Option(
|
|
273
|
+
DEFAULT_CONTRACT, "--out", "-o", help="Lockfile to create or reuse."
|
|
274
|
+
),
|
|
275
|
+
force: bool = typer.Option(False, "--force", help="Overwrite an existing lockfile."),
|
|
276
|
+
) -> None:
|
|
277
|
+
"""Start here: make a lockfile from your URDF.
|
|
278
|
+
|
|
279
|
+
A fresh lockfile knows the frame and nothing else; the app shows what to fill in next.
|
|
280
|
+
"""
|
|
281
|
+
_refuse_default_out_when_frozen(ctx, "contract", "start")
|
|
282
|
+
from lockwire.start_operation import start_contract
|
|
283
|
+
|
|
284
|
+
try:
|
|
285
|
+
result = start_contract(
|
|
286
|
+
urdf,
|
|
287
|
+
out=contract,
|
|
288
|
+
overwrite=force and contract.exists(),
|
|
289
|
+
expected_revision=contract_revision(contract) if force and contract.exists() else None,
|
|
290
|
+
)
|
|
291
|
+
except (OSError, TypeError, ValueError, YAMLError) as error:
|
|
292
|
+
console.print(f"[red]start failed:[/] {error}")
|
|
293
|
+
raise typer.Exit(1) from error
|
|
294
|
+
try:
|
|
295
|
+
model = load_contract(contract).model
|
|
296
|
+
except (OSError, TypeError, ValueError, YAMLError) as error:
|
|
297
|
+
console.print(f"[red]start failed:[/] {error}")
|
|
298
|
+
raise typer.Exit(1) from error
|
|
299
|
+
if not result["written"]:
|
|
300
|
+
console.print(
|
|
301
|
+
f"[yellow]{contract} already exists[/] — kept as is. "
|
|
302
|
+
"Pass --force to start over from the URDF (this discards locked entries)."
|
|
303
|
+
)
|
|
304
|
+
else:
|
|
305
|
+
console.print(f"[green]wrote {contract}[/] — {len(model.links)} links from {urdf}")
|
|
306
|
+
if result.get("note"):
|
|
307
|
+
console.print(f"[yellow]{result['note']}[/]")
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
@app.command()
|
|
311
|
+
def init(
|
|
312
|
+
urdf: Path = typer.Argument(
|
|
313
|
+
..., exists=True, readable=True, help="URDF or SDF to bootstrap Tier A from."
|
|
314
|
+
),
|
|
315
|
+
out: Path = typer.Option(DEFAULT_CONTRACT, "--out", "-o", help="Contract file to write."),
|
|
316
|
+
robot: str = typer.Option(None, "--robot", help="Robot name (default: URDF stem)."),
|
|
317
|
+
force: bool = typer.Option(False, "--force", help="Overwrite an existing contract."),
|
|
318
|
+
) -> None:
|
|
319
|
+
"""Bootstrap a contract from a URDF: Tier A links, structure hash, zero-mass flags."""
|
|
320
|
+
if out.exists() and not force:
|
|
321
|
+
console.print(
|
|
322
|
+
f"[red]{out} exists[/] — pass --force to overwrite (this discards locked entries)"
|
|
323
|
+
)
|
|
324
|
+
raise typer.Exit(1)
|
|
325
|
+
|
|
326
|
+
# Unexpanded xacro is tool failure (§3.3 exit 2), not a NO-GO finding.
|
|
327
|
+
try:
|
|
328
|
+
model = _parse_model_file(urdf, out)
|
|
329
|
+
except ValueError as error:
|
|
330
|
+
console.print(f"[red]{error}[/]")
|
|
331
|
+
raise typer.Exit(2) from error
|
|
332
|
+
contract = Contract(robot=RobotInfo(name=robot or urdf.stem), model=model)
|
|
333
|
+
save_contract(contract, out)
|
|
334
|
+
|
|
335
|
+
console.print(
|
|
336
|
+
f"[green]wrote {out}[/] — {len(model.links)} links, structure {model.structure_hash[:12]}…"
|
|
337
|
+
)
|
|
338
|
+
if model.zero_mass_links:
|
|
339
|
+
console.print(
|
|
340
|
+
f"[yellow]{len(model.zero_mass_links)} zero-mass link(s)[/]: "
|
|
341
|
+
+ ", ".join(model.zero_mass_links)
|
|
342
|
+
+ " — `lockwire check` will warn until resolved"
|
|
343
|
+
)
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
@app.command(
|
|
347
|
+
"upgrade",
|
|
348
|
+
help=f"Write a new 0.4 contract from a pinned legacy file.\n\n{exit_code_help('upgrade')}",
|
|
349
|
+
)
|
|
350
|
+
def upgrade_cmd(
|
|
351
|
+
contract: Path = typer.Argument(..., help="Legacy 0.2 or 0.3 contract to retain unchanged."),
|
|
352
|
+
out: Path = typer.Option(..., "--out", help="New 0.4 contract path; never overwritten."),
|
|
353
|
+
expected_revision: str = typer.Option(
|
|
354
|
+
..., "--expected-revision", help="Full SHA-256 of the legacy file's exact bytes."
|
|
355
|
+
),
|
|
356
|
+
) -> None:
|
|
357
|
+
"""Explicitly write a new 0.4 contract while retaining the legacy file."""
|
|
358
|
+
try:
|
|
359
|
+
api.upgrade(contract, out=out, expected_revision=expected_revision)
|
|
360
|
+
except (OSError, UnicodeError, ValueError) as error:
|
|
361
|
+
_refused("upgrade", error)
|
|
362
|
+
raise typer.Exit(2) from error
|
|
363
|
+
console.print(f"[green]wrote {out}[/] — legacy {contract} retained unchanged")
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
@app.command(
|
|
367
|
+
help=f"Run record-level checks: do the records agree? (CI entry point)\n\n{exit_code_help('check')}"
|
|
368
|
+
)
|
|
369
|
+
def check(
|
|
370
|
+
contract: Path = typer.Argument(DEFAULT_CONTRACT, help="Contract file to check."),
|
|
371
|
+
json_out: Path | None = typer.Option(
|
|
372
|
+
None, "--json", help="Also write this same run as JSON (the `lockwire.api.check` document)."
|
|
373
|
+
),
|
|
374
|
+
actuator_evidence: list[str] | None = typer.Option(
|
|
375
|
+
None,
|
|
376
|
+
"--actuator-evidence",
|
|
377
|
+
help="Repeat CASE_ID=RELATIVE_PATH@SHA256 for selected actuator-case evidence.",
|
|
378
|
+
),
|
|
379
|
+
evidence_root: Path | None = typer.Option(
|
|
380
|
+
None, "--evidence-root", help="Root containing the selected relative evidence paths."
|
|
381
|
+
),
|
|
382
|
+
expected_revision: str | None = typer.Option(
|
|
383
|
+
None, "--expected-revision", help="Full raw contract SHA-256 used to select evidence."
|
|
384
|
+
),
|
|
385
|
+
) -> None:
|
|
386
|
+
"""Run record-level checks: do the records agree? (CI entry point)"""
|
|
387
|
+
if not contract.exists():
|
|
388
|
+
console.print(f"[red]no contract at {contract}[/] — run `lockwire init <urdf>` first")
|
|
389
|
+
raise typer.Exit(2)
|
|
390
|
+
try:
|
|
391
|
+
loaded = load_contract(contract)
|
|
392
|
+
selected = _parse_actuator_evidence(actuator_evidence) if actuator_evidence else None
|
|
393
|
+
selected, root, revision = api._actuator_check_context(
|
|
394
|
+
loaded, contract, selected, evidence_root, expected_revision
|
|
395
|
+
)
|
|
396
|
+
except (OSError, TypeError, ValueError, YAMLError) as error:
|
|
397
|
+
console.print(f"[red]check failed:[/] {error}")
|
|
398
|
+
raise typer.Exit(2) from error
|
|
399
|
+
findings = run_record_checks(
|
|
400
|
+
loaded,
|
|
401
|
+
contract_path=contract,
|
|
402
|
+
actuator_evidence=selected,
|
|
403
|
+
evidence_root=root,
|
|
404
|
+
contract_sha256=revision,
|
|
405
|
+
)
|
|
406
|
+
# The document is built by the library, never assembled here: `lockwire.api`
|
|
407
|
+
# and this file must not be able to disagree about one run.
|
|
408
|
+
# `check_document`, not `verdict_document` directly, so this JSON carries the
|
|
409
|
+
# same `headline` `api.check` does -- one document builder, not two.
|
|
410
|
+
_write_document(
|
|
411
|
+
json_out,
|
|
412
|
+
api.check_document(
|
|
413
|
+
loaded,
|
|
414
|
+
findings,
|
|
415
|
+
revision=revision,
|
|
416
|
+
actuator_evidence=selected,
|
|
417
|
+
),
|
|
418
|
+
)
|
|
419
|
+
raise typer.Exit(render(findings, console))
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
def _parse_actuator_evidence(values: list[str]) -> dict[str, ArtifactRef]:
|
|
423
|
+
"""Parse the Check CLI's repeatable, shell-friendly ArtifactRef spelling once."""
|
|
424
|
+
parsed: dict[str, ArtifactRef] = {}
|
|
425
|
+
for value in values:
|
|
426
|
+
if value.count("=") != 1 or "@" not in value:
|
|
427
|
+
raise ValueError("actuator evidence must be CASE_ID=RELATIVE_PATH@SHA256")
|
|
428
|
+
case_id, encoded_ref = value.split("=", 1)
|
|
429
|
+
relative_path, separator, sha256 = encoded_ref.rpartition("@")
|
|
430
|
+
if not case_id or not separator or not relative_path or not sha256:
|
|
431
|
+
raise ValueError("actuator evidence must be CASE_ID=RELATIVE_PATH@SHA256")
|
|
432
|
+
if case_id in parsed:
|
|
433
|
+
raise ValueError(f"duplicate actuator evidence for case {case_id!r}")
|
|
434
|
+
parsed[case_id] = ArtifactRef(path=relative_path, sha256=sha256)
|
|
435
|
+
return parsed
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
@app.command(
|
|
439
|
+
"plan",
|
|
440
|
+
help="Check an agent's intended plan against the contract before anything executes."
|
|
441
|
+
f"\n\n{exit_code_help('plan')}",
|
|
442
|
+
)
|
|
443
|
+
def plan_cmd(
|
|
444
|
+
contract: Path = typer.Argument(..., help="Contract file to check the plan against."),
|
|
445
|
+
plan: Path = typer.Argument(..., help="Plan file (plan.yaml) to validate."),
|
|
446
|
+
json_out: Path | None = typer.Option(
|
|
447
|
+
None, "--json", help="Also write this same run as JSON (the `lockwire.api.plan` document)."
|
|
448
|
+
),
|
|
449
|
+
) -> None:
|
|
450
|
+
"""Check an agent's intended plan against the contract before anything executes."""
|
|
451
|
+
if not contract.exists() or not plan.exists():
|
|
452
|
+
console.print("[red]contract or plan file not found[/]")
|
|
453
|
+
raise typer.Exit(2)
|
|
454
|
+
try:
|
|
455
|
+
loaded_contract = load_contract(contract)
|
|
456
|
+
loaded_plan = load_plan(plan)
|
|
457
|
+
except (OSError, TypeError, ValueError, YAMLError) as error:
|
|
458
|
+
console.print(f"[red]plan check failed:[/] {error}")
|
|
459
|
+
raise typer.Exit(2) from error
|
|
460
|
+
findings = validate_plan(loaded_contract, loaded_plan)
|
|
461
|
+
_write_document(json_out, plugins.pick("plan", api.plan)(loaded_contract, loaded_plan))
|
|
462
|
+
raise typer.Exit(render(findings, console))
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
@app.command(
|
|
466
|
+
"bench",
|
|
467
|
+
help="Score a corpus of generated out-of-bound plans: detection (via `plan`'s own "
|
|
468
|
+
"evaluator — missed/false-alarm/unscorable) and forwarding (bytes, order, count, "
|
|
469
|
+
"regardless of verdict) measured separately, on the given contract."
|
|
470
|
+
f"\n\n{exit_code_help('bench')}",
|
|
471
|
+
)
|
|
472
|
+
def bench_cmd(
|
|
473
|
+
contract: Path = typer.Argument(..., help="Contract file to score the corpus against."),
|
|
474
|
+
corpus: Path = typer.Option(
|
|
475
|
+
DEFAULT_CORPUS_PATH,
|
|
476
|
+
"--corpus",
|
|
477
|
+
help="Corpus YAML file (id/category/step per case).",
|
|
478
|
+
),
|
|
479
|
+
) -> None:
|
|
480
|
+
"""Run `tests/fixtures/bench/corpus.yaml` (by default) against `contract`."""
|
|
481
|
+
if not contract.exists() or not corpus.exists():
|
|
482
|
+
console.print("[red]contract or corpus file not found[/]")
|
|
483
|
+
raise typer.Exit(2)
|
|
484
|
+
try:
|
|
485
|
+
loaded_contract = load_contract(contract)
|
|
486
|
+
cases = load_corpus(corpus)
|
|
487
|
+
except (OSError, TypeError, ValueError, YAMLError) as error:
|
|
488
|
+
console.print(f"[red]bench failed:[/] {error}")
|
|
489
|
+
raise typer.Exit(2) from error
|
|
490
|
+
result, _sink = run_bench(loaded_contract, cases)
|
|
491
|
+
console.print(format_report(result, corpus))
|
|
492
|
+
raise typer.Exit(result.exit_code)
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
@app.command()
|
|
496
|
+
def report(
|
|
497
|
+
ctx: typer.Context,
|
|
498
|
+
contract: Path = typer.Argument(
|
|
499
|
+
DEFAULT_CONTRACT,
|
|
500
|
+
exists=True,
|
|
501
|
+
dir_okay=False,
|
|
502
|
+
readable=True,
|
|
503
|
+
help="Contract file to report.",
|
|
504
|
+
),
|
|
505
|
+
scene: Path | None = typer.Option(
|
|
506
|
+
None,
|
|
507
|
+
"--scene",
|
|
508
|
+
help="A glTF scene (.glb/.gltf) or a URDF/SDF (.urdf/.sdf) of the robot; parts named "
|
|
509
|
+
"by contract id in extras, or by link name for a URDF/SDF.",
|
|
510
|
+
),
|
|
511
|
+
package_root: list[Path] = typer.Option(
|
|
512
|
+
[],
|
|
513
|
+
"--package-root",
|
|
514
|
+
help="A folder containing a ROS package (repeatable), for resolving package:// / "
|
|
515
|
+
"model:// mesh URIs in a URDF/SDF scene.",
|
|
516
|
+
),
|
|
517
|
+
out: Path = typer.Option(..., "--out", "-o", help="Self-contained HTML file to write."),
|
|
518
|
+
open_browser: bool = typer.Option(
|
|
519
|
+
True,
|
|
520
|
+
"--open/--no-open",
|
|
521
|
+
help="report never opens a browser; the flag is accepted so scripts can pass "
|
|
522
|
+
"it everywhere.",
|
|
523
|
+
),
|
|
524
|
+
redact_paths: bool = typer.Option(
|
|
525
|
+
False,
|
|
526
|
+
"--redact-paths",
|
|
527
|
+
help="Shorten absolute paths to their basename and drop owner/witness names — "
|
|
528
|
+
"for a page you're about to send someone.",
|
|
529
|
+
),
|
|
530
|
+
) -> None:
|
|
531
|
+
"""Emit a deterministic, self-contained contract report."""
|
|
532
|
+
del open_browser # accepted for CLI parity with gui/start; no-op.
|
|
533
|
+
try:
|
|
534
|
+
loaded = load_contract(contract)
|
|
535
|
+
except (OSError, TypeError, ValueError, YAMLError) as error:
|
|
536
|
+
console.print(f"[red]report failed:[/] {error}")
|
|
537
|
+
raise typer.Exit(1) from error
|
|
538
|
+
# Announces/refuses the scene for CLI parity; the printable report
|
|
539
|
+
# does not yet draw it — that is the 3D renderer's ticket, not this one's.
|
|
540
|
+
_load_scene_for_cli(contract, scene, command="report", package_roots=tuple(package_root))
|
|
541
|
+
# --out is excluded and rendered as a placeholder, same convention as gui's
|
|
542
|
+
# own line: the page's bytes must not depend on the chosen output filename.
|
|
543
|
+
cli_line = build_cli_line(
|
|
544
|
+
"lockwire", "report", *argv_from_context(ctx, exclude={"out"}), "--out", "<this file>"
|
|
545
|
+
)
|
|
546
|
+
write_html_report(
|
|
547
|
+
loaded, out, contract_path=contract, redact_paths=redact_paths, cli_line=cli_line
|
|
548
|
+
)
|
|
549
|
+
console.print(f"[green]wrote {out}[/]")
|
|
550
|
+
|
|
551
|
+
|
|
552
|
+
@app.command()
|
|
553
|
+
def describe(
|
|
554
|
+
contract: Path = typer.Argument(
|
|
555
|
+
DEFAULT_CONTRACT,
|
|
556
|
+
exists=True,
|
|
557
|
+
dir_okay=False,
|
|
558
|
+
readable=True,
|
|
559
|
+
help="Contract file to describe.",
|
|
560
|
+
),
|
|
561
|
+
out: Path | None = typer.Option(
|
|
562
|
+
None, "--out", "-o", help="Directory to write one Markdown file per device, plus SYSTEM.md."
|
|
563
|
+
),
|
|
564
|
+
) -> None:
|
|
565
|
+
"""Print a per-device reference file, generated from the contract."""
|
|
566
|
+
try:
|
|
567
|
+
loaded = load_contract(contract)
|
|
568
|
+
except (OSError, TypeError, ValueError, YAMLError) as error:
|
|
569
|
+
console.print(f"[red]describe failed:[/] {error}")
|
|
570
|
+
raise typer.Exit(2) from error
|
|
571
|
+
|
|
572
|
+
if out is None:
|
|
573
|
+
for device in loaded.devices:
|
|
574
|
+
console.print(describe_device(loaded, device))
|
|
575
|
+
# The system page carries the force numbers. Printing only the
|
|
576
|
+
# per-device pages would leave stdout without the figures --out writes.
|
|
577
|
+
console.print(describe_system(loaded))
|
|
578
|
+
return
|
|
579
|
+
|
|
580
|
+
try:
|
|
581
|
+
out.mkdir(parents=True, exist_ok=True)
|
|
582
|
+
files = describe_all(loaded)
|
|
583
|
+
for name, content in files.items():
|
|
584
|
+
(out / name).write_text(content, encoding="utf-8")
|
|
585
|
+
except OSError as error:
|
|
586
|
+
console.print(f"[red]describe failed:[/] could not write to {out}: {error}")
|
|
587
|
+
raise typer.Exit(2) from error
|
|
588
|
+
|
|
589
|
+
for name in sorted(files):
|
|
590
|
+
console.print(f"[green]wrote {out / name}[/]")
|
|
591
|
+
|
|
592
|
+
|
|
593
|
+
@app.command("export")
|
|
594
|
+
def export_cmd(
|
|
595
|
+
contract: Path = typer.Argument(
|
|
596
|
+
DEFAULT_CONTRACT,
|
|
597
|
+
exists=True,
|
|
598
|
+
dir_okay=False,
|
|
599
|
+
readable=True,
|
|
600
|
+
help="Contract file to export.",
|
|
601
|
+
),
|
|
602
|
+
fmt: str = typer.Option(..., "--format", help="Output shape. Only 'mhs' exists."),
|
|
603
|
+
) -> None:
|
|
604
|
+
"""Render the contract into a third-party device-manifest shape. Outward only."""
|
|
605
|
+
if fmt != "mhs":
|
|
606
|
+
console.print(f"[red]export failed:[/] unknown --format {fmt!r}; only 'mhs' exists.")
|
|
607
|
+
raise typer.Exit(2)
|
|
608
|
+
try:
|
|
609
|
+
loaded = load_contract(contract)
|
|
610
|
+
except (OSError, TypeError, ValueError, YAMLError) as error:
|
|
611
|
+
console.print(f"[red]export failed:[/] {error}")
|
|
612
|
+
raise typer.Exit(2) from error
|
|
613
|
+
console.print_json(data=export_mhs.export_mhs(loaded))
|
|
614
|
+
|
|
615
|
+
|
|
616
|
+
@app.command(
|
|
617
|
+
"import",
|
|
618
|
+
help="Refused: importing a third-party manifest into a contract is not built."
|
|
619
|
+
f"\n\n{exit_code_help('import')}",
|
|
620
|
+
)
|
|
621
|
+
def import_cmd(
|
|
622
|
+
manifest: Path = typer.Argument(
|
|
623
|
+
..., exists=True, dir_okay=False, readable=True, help="Manifest file (refused)."
|
|
624
|
+
),
|
|
625
|
+
) -> None:
|
|
626
|
+
"""Refused: importing a third-party manifest into a contract is not built."""
|
|
627
|
+
console.print(
|
|
628
|
+
"[bold red]REFUSED[/] — The inbound direction — importing a third-party manifest "
|
|
629
|
+
"into a contract — is refused until Anthropic publishes the standard; import is "
|
|
630
|
+
"where lock-in happens."
|
|
631
|
+
)
|
|
632
|
+
raise typer.Exit(2)
|
|
633
|
+
|
|
634
|
+
|
|
635
|
+
@app.command()
|
|
636
|
+
def scan(
|
|
637
|
+
robot: str = typer.Option(
|
|
638
|
+
"scanned-robot", "--robot", help="Robot name for the draft candidate."
|
|
639
|
+
),
|
|
640
|
+
) -> None:
|
|
641
|
+
"""Enumerate this Linux/ROS 2 machine into a draft, never an authoritative lock."""
|
|
642
|
+
try:
|
|
643
|
+
draft = scan_machine(robot)
|
|
644
|
+
except UnsupportedPlatformError as error:
|
|
645
|
+
console.print(f"[red]scan failed:[/] {error}")
|
|
646
|
+
raise typer.Exit(1) from error
|
|
647
|
+
|
|
648
|
+
yaml = YAML()
|
|
649
|
+
yaml.default_flow_style = False
|
|
650
|
+
output = io.StringIO()
|
|
651
|
+
yaml.dump(draft.model_dump(mode="json", exclude_none=True), output)
|
|
652
|
+
typer.echo(output.getvalue(), nl=False)
|
|
653
|
+
|
|
654
|
+
|
|
655
|
+
def _parse_hw_timestamp(raw: str | None) -> dt.datetime | None:
|
|
656
|
+
if raw is None:
|
|
657
|
+
return None
|
|
658
|
+
text = raw.strip()
|
|
659
|
+
if text.endswith("Z"):
|
|
660
|
+
text = text[:-1] + "+00:00"
|
|
661
|
+
when = dt.datetime.fromisoformat(text)
|
|
662
|
+
if when.tzinfo is None:
|
|
663
|
+
when = when.replace(tzinfo=dt.UTC)
|
|
664
|
+
return when
|
|
665
|
+
|
|
666
|
+
|
|
667
|
+
@scene_app.command(
|
|
668
|
+
"import",
|
|
669
|
+
help="Import device placements from a 3D scene into a change file.\n\n"
|
|
670
|
+
f"{exit_code_help('scene import')}",
|
|
671
|
+
)
|
|
672
|
+
def scene_import_cmd(
|
|
673
|
+
ctx: typer.Context,
|
|
674
|
+
scene: Path = typer.Argument(
|
|
675
|
+
..., help="A glTF scene (.glb/.gltf) or a URDF/SDF (.urdf/.sdf) of the robot."
|
|
676
|
+
),
|
|
677
|
+
contract: Path = typer.Option(
|
|
678
|
+
DEFAULT_CONTRACT,
|
|
679
|
+
"--contract",
|
|
680
|
+
"-c",
|
|
681
|
+
help="Contract file to resolve ids against; --write also applies the changes.",
|
|
682
|
+
),
|
|
683
|
+
witness: str | None = typer.Option(
|
|
684
|
+
None, "--witness", help="Who/what witnesses these placements, e.g. 'human'."
|
|
685
|
+
),
|
|
686
|
+
source_person: str | None = typer.Option(
|
|
687
|
+
None, "--source-person", help="Name of the person confirming each placement."
|
|
688
|
+
),
|
|
689
|
+
out: Path = typer.Option(
|
|
690
|
+
Path("changes.yaml"), "--out", "-o", help="Where to write the change file."
|
|
691
|
+
),
|
|
692
|
+
only: list[str] = typer.Option([], "--only", help="Limit to these device ids (repeatable)."),
|
|
693
|
+
declared_too: bool = typer.Option(
|
|
694
|
+
False,
|
|
695
|
+
"--declared-too",
|
|
696
|
+
help="Also correct devices that already declare mount_xyz but disagree with "
|
|
697
|
+
"the model past 5 mm.",
|
|
698
|
+
),
|
|
699
|
+
package_root: list[Path] = typer.Option(
|
|
700
|
+
[],
|
|
701
|
+
"--package-root",
|
|
702
|
+
help="A folder containing a ROS package (repeatable), for resolving package:// / "
|
|
703
|
+
"model:// mesh URIs in a URDF/SDF scene.",
|
|
704
|
+
),
|
|
705
|
+
write: bool = typer.Option(
|
|
706
|
+
False, "--write", help="Apply the change file immediately (needs `lockwire apply`)."
|
|
707
|
+
),
|
|
708
|
+
expected_revision: str | None = typer.Option(
|
|
709
|
+
None,
|
|
710
|
+
"--expected-revision",
|
|
711
|
+
help="Full raw contract SHA-256 required with --write.",
|
|
712
|
+
),
|
|
713
|
+
) -> None:
|
|
714
|
+
"""Read undeclared device placements from a scene into an apply-ready change file.
|
|
715
|
+
|
|
716
|
+
The scene alone never mutates the contract; --write applies the
|
|
717
|
+
imported changes. Every entry gets its own source naming the model node
|
|
718
|
+
and the confirming person, plus the given witness.
|
|
719
|
+
For a URDF/SDF the parts are links (ids = link names); a device joins only
|
|
720
|
+
when its id equals a link name, or the model gives it extras.lockwire.id.
|
|
721
|
+
"""
|
|
722
|
+
if witness is None or source_person is None:
|
|
723
|
+
console.print("[red]scene import failed:[/] --witness and --source-person are required.")
|
|
724
|
+
raise typer.Exit(2)
|
|
725
|
+
if write and expected_revision is None:
|
|
726
|
+
console.print("[red]scene import failed:[/] --write requires --expected-revision.")
|
|
727
|
+
raise typer.Exit(2)
|
|
728
|
+
if not contract.exists() or not contract.is_file():
|
|
729
|
+
console.print(f"[red]scene import failed:[/] no contract at {contract}")
|
|
730
|
+
raise typer.Exit(2)
|
|
731
|
+
if write and expected_revision != contract_revision(contract):
|
|
732
|
+
console.print(
|
|
733
|
+
"[red]scene import failed:[/] contract changed since it was opened; "
|
|
734
|
+
"reload before applying this edit"
|
|
735
|
+
)
|
|
736
|
+
raise typer.Exit(2)
|
|
737
|
+
try:
|
|
738
|
+
loaded_contract = load_contract(contract)
|
|
739
|
+
except (OSError, TypeError, ValueError, YAMLError) as error:
|
|
740
|
+
console.print(f"[red]scene import failed:[/] {error}")
|
|
741
|
+
raise typer.Exit(2) from error
|
|
742
|
+
link_based = scene.suffix.lower() in (".urdf", ".sdf", ".xacro")
|
|
743
|
+
try:
|
|
744
|
+
loaded_scene, _ = api.load_scene(contract, scene, tuple(package_root))
|
|
745
|
+
assert loaded_scene is not None
|
|
746
|
+
except SceneRefusal as error:
|
|
747
|
+
console.print(f"[red]{error}[/]")
|
|
748
|
+
raise typer.Exit(2) from error
|
|
749
|
+
|
|
750
|
+
try:
|
|
751
|
+
result = build_import(
|
|
752
|
+
loaded_contract,
|
|
753
|
+
loaded_scene,
|
|
754
|
+
witness=witness,
|
|
755
|
+
source_person=source_person,
|
|
756
|
+
only=list(only) or None,
|
|
757
|
+
declared_too=declared_too,
|
|
758
|
+
link_based=link_based,
|
|
759
|
+
)
|
|
760
|
+
except SceneImportRefusal as error:
|
|
761
|
+
console.print(f"[red]scene import failed:[/] {error}")
|
|
762
|
+
raise typer.Exit(2) from error
|
|
763
|
+
|
|
764
|
+
if result.explanation is not None:
|
|
765
|
+
console.print(result.explanation)
|
|
766
|
+
|
|
767
|
+
console.print("device · model position (m) · declared · action")
|
|
768
|
+
for row in result.rows:
|
|
769
|
+
model_str = format_point(row.model_position) if row.model_position is not None else "-"
|
|
770
|
+
declared_str = format_point(row.declared) if row.declared is not None else "-"
|
|
771
|
+
console.print(f" {row.device_id} · {model_str} · {declared_str} · {row.action}")
|
|
772
|
+
if result.skipped_non_devices:
|
|
773
|
+
skipped = ", ".join(result.skipped_non_devices)
|
|
774
|
+
console.print(f"skipped (not devices): {skipped}")
|
|
775
|
+
|
|
776
|
+
try:
|
|
777
|
+
if write:
|
|
778
|
+
write_and_apply_import(
|
|
779
|
+
contract,
|
|
780
|
+
result.entries,
|
|
781
|
+
out,
|
|
782
|
+
scene_source=loaded_scene.source,
|
|
783
|
+
expected_revision=expected_revision,
|
|
784
|
+
)
|
|
785
|
+
else:
|
|
786
|
+
write_change_file(result.entries, out, scene_source=loaded_scene.source)
|
|
787
|
+
except (EditRefusal, SceneImportRefusal, OSError, YAMLError) as error:
|
|
788
|
+
console.print(f"[red]scene import failed:[/] {error}")
|
|
789
|
+
raise typer.Exit(2) from error
|
|
790
|
+
console.print(f"wrote {out} ({len(result.entries)} placements, witness {witness})")
|
|
791
|
+
|
|
792
|
+
console.print(
|
|
793
|
+
"ran: " + build_cli_line("lockwire", "scene", "import", *argv_from_context(ctx)),
|
|
794
|
+
soft_wrap=True,
|
|
795
|
+
)
|
|
796
|
+
|
|
797
|
+
if write:
|
|
798
|
+
console.print(f"applied {out} to {contract}")
|
|
799
|
+
else:
|
|
800
|
+
console.print(f"next: lockwire apply {contract} {out}")
|
|
801
|
+
|
|
802
|
+
|
|
803
|
+
@app.command(
|
|
804
|
+
"hw",
|
|
805
|
+
help="Record a W0 hardware-change event and route it to touched entries/owners."
|
|
806
|
+
f"\n\n{exit_code_help('hw')}",
|
|
807
|
+
)
|
|
808
|
+
def hw(
|
|
809
|
+
declaration: str = typer.Argument(
|
|
810
|
+
...,
|
|
811
|
+
help="Hardware-change declaration, e.g. 'added FLIR, 42 g, nose mount'.",
|
|
812
|
+
),
|
|
813
|
+
contract: Path = typer.Option(
|
|
814
|
+
DEFAULT_CONTRACT,
|
|
815
|
+
"--contract",
|
|
816
|
+
"-c",
|
|
817
|
+
help="Contract file to route against (never modified).",
|
|
818
|
+
),
|
|
819
|
+
device: str | None = typer.Option(
|
|
820
|
+
None,
|
|
821
|
+
"--device",
|
|
822
|
+
help="Existing device id to bind bus/rail routing from the contract.",
|
|
823
|
+
),
|
|
824
|
+
bus: str | None = typer.Option(
|
|
825
|
+
None,
|
|
826
|
+
"--bus",
|
|
827
|
+
help="Bus id the change sits on (when not derived from --device).",
|
|
828
|
+
),
|
|
829
|
+
rail: str | None = typer.Option(
|
|
830
|
+
None,
|
|
831
|
+
"--rail",
|
|
832
|
+
help="Rail id the change draws from (when not derived from --device).",
|
|
833
|
+
),
|
|
834
|
+
at: str | None = typer.Option(
|
|
835
|
+
None,
|
|
836
|
+
"--at",
|
|
837
|
+
help="Event timestamp (ISO-8601). Default: now UTC. Pin for byte-identical output.",
|
|
838
|
+
),
|
|
839
|
+
) -> None:
|
|
840
|
+
"""Record a W0 hardware-change event and route it to touched entries/owners.
|
|
841
|
+
|
|
842
|
+
Appends to a sidecar ``<contract-stem>.hw.yaml`` beside the contract. Does
|
|
843
|
+
not mutate the contract. See ``report.EXIT_CODE_TABLE["hw"]`` for exit codes.
|
|
844
|
+
"""
|
|
845
|
+
if not contract.exists() or not contract.is_file():
|
|
846
|
+
console.print(f"[red]hw failed:[/] no contract at {contract}")
|
|
847
|
+
raise typer.Exit(2)
|
|
848
|
+
try:
|
|
849
|
+
recorded_at = _parse_hw_timestamp(at)
|
|
850
|
+
loaded = load_contract(contract)
|
|
851
|
+
event, log_path, routing = record_hardware_change(
|
|
852
|
+
loaded,
|
|
853
|
+
contract,
|
|
854
|
+
declaration,
|
|
855
|
+
recorded_at=recorded_at,
|
|
856
|
+
device=device,
|
|
857
|
+
bus=bus,
|
|
858
|
+
rail=rail,
|
|
859
|
+
)
|
|
860
|
+
except (OSError, TypeError, ValueError, YAMLError) as error:
|
|
861
|
+
console.print(f"[red]hw failed:[/] {error}")
|
|
862
|
+
raise typer.Exit(2) from error
|
|
863
|
+
|
|
864
|
+
qty = ", ".join(str(q) for q in event.quantities) if event.quantities else "(none parsed)"
|
|
865
|
+
console.print(f"[green]recorded[/] {event.declaration}")
|
|
866
|
+
console.print(f" quantities: {qty}")
|
|
867
|
+
console.print(f" at: {event.recorded_at.isoformat()}")
|
|
868
|
+
console.print(f" log: {log_path}")
|
|
869
|
+
console.print("possibly touched:")
|
|
870
|
+
if routing.touched:
|
|
871
|
+
for entry in routing.touched:
|
|
872
|
+
owner = entry.owner if entry.owner is not None else "(no owner)"
|
|
873
|
+
console.print(f" {entry.path} owner={owner} ({entry.reason})")
|
|
874
|
+
else:
|
|
875
|
+
console.print(" (none)")
|
|
876
|
+
if routing.undetermined:
|
|
877
|
+
console.print("[yellow]undetermined:[/]")
|
|
878
|
+
for gap in routing.undetermined:
|
|
879
|
+
console.print(f" {gap}")
|
|
880
|
+
|
|
881
|
+
|
|
882
|
+
def _parse_locked_at(raw: str | None) -> dt.date | None:
|
|
883
|
+
return dt.date.fromisoformat(raw) if raw is not None else None
|
|
884
|
+
|
|
885
|
+
|
|
886
|
+
def _print_edit_result(result) -> None:
|
|
887
|
+
if result.diff:
|
|
888
|
+
console.print(result.diff, end="")
|
|
889
|
+
for line in result.lines:
|
|
890
|
+
console.print(f"[green]{line}[/]")
|
|
891
|
+
if not result.written:
|
|
892
|
+
console.print("[yellow](dry run — nothing written)[/]")
|
|
893
|
+
|
|
894
|
+
|
|
895
|
+
@app.command(
|
|
896
|
+
"declare",
|
|
897
|
+
help="Declare one fact in the contract: a value with a source and a witness."
|
|
898
|
+
f"\n\n{exit_code_help('declare')}",
|
|
899
|
+
)
|
|
900
|
+
def declare_cmd(
|
|
901
|
+
ctx: typer.Context,
|
|
902
|
+
contract: Path = typer.Argument(..., help="Contract file to edit."),
|
|
903
|
+
path: str = typer.Argument(
|
|
904
|
+
..., help="'/'-separated contract path, e.g. devices/dev.x/physical/mount_xyz."
|
|
905
|
+
),
|
|
906
|
+
value: str = typer.Argument(
|
|
907
|
+
..., help="Value to declare (number, '25 W', a quoted string, true/false)."
|
|
908
|
+
),
|
|
909
|
+
source: str = typer.Option(..., "--source", help="Where the fact comes from (required)."),
|
|
910
|
+
witness: str | None = typer.Option(
|
|
911
|
+
None,
|
|
912
|
+
"--witness",
|
|
913
|
+
help="Who/what measured it; omitted falls back to the per-machine config "
|
|
914
|
+
'default, marked "(default)" — still refused if neither is given.',
|
|
915
|
+
),
|
|
916
|
+
note: str | None = typer.Option(None, "--note", help="Appended to the owning block's note."),
|
|
917
|
+
locked_at: str | None = typer.Option(
|
|
918
|
+
None, "--locked-at", help="ISO date to stamp instead of today (tests only)."
|
|
919
|
+
),
|
|
920
|
+
dry_run: bool = typer.Option(False, "--dry-run", help="Print the diff, write nothing."),
|
|
921
|
+
) -> None:
|
|
922
|
+
"""Declare one fact in the contract: a value with a source and a witness.
|
|
923
|
+
|
|
924
|
+
Comments, key order and quoting elsewhere in the file survive (ruamel
|
|
925
|
+
round-trip). Refuses without --source/--witness or a value the schema
|
|
926
|
+
rejects; nothing is written on refusal. A --witness omitted (rather than
|
|
927
|
+
passed blank) may still be supplied by the per-machine config default,
|
|
928
|
+
marked "(default)" — see ``resolve_witness``.
|
|
929
|
+
"""
|
|
930
|
+
if not contract.exists() or not contract.is_file():
|
|
931
|
+
console.print(f"[red]declare failed:[/] no contract at {contract}")
|
|
932
|
+
raise typer.Exit(2)
|
|
933
|
+
try:
|
|
934
|
+
change = Change(
|
|
935
|
+
path=path,
|
|
936
|
+
value=value,
|
|
937
|
+
source=source,
|
|
938
|
+
witness=resolve_witness(witness),
|
|
939
|
+
note=note,
|
|
940
|
+
locked_at=_parse_locked_at(locked_at),
|
|
941
|
+
)
|
|
942
|
+
result = apply_changes(contract, [change], dry_run=dry_run)
|
|
943
|
+
except (EditRefusal, UnsupportedSchemaVersion, OSError, YAMLError) as error:
|
|
944
|
+
_refused("declare", error)
|
|
945
|
+
raise typer.Exit(2) from error
|
|
946
|
+
_print_edit_result(result)
|
|
947
|
+
console.print(
|
|
948
|
+
"ran: " + build_cli_line("lockwire", "declare", *argv_from_context(ctx)), soft_wrap=True
|
|
949
|
+
)
|
|
950
|
+
|
|
951
|
+
|
|
952
|
+
@app.command(
|
|
953
|
+
"what-if",
|
|
954
|
+
help="Compare a proposal without changing the contract or health ledger."
|
|
955
|
+
f"\n\n{exit_code_help('what-if')}",
|
|
956
|
+
)
|
|
957
|
+
def what_if_cmd(
|
|
958
|
+
contract: Path = typer.Argument(..., help="Contract to compare."),
|
|
959
|
+
change: list[str] = typer.Option(..., "--change", help="Repeat PATH=VALUE for each proposal."),
|
|
960
|
+
source: str = typer.Option(..., "--source", help="Proposal source."),
|
|
961
|
+
witness: str | None = typer.Option(
|
|
962
|
+
None, "--witness", help="Proposal witness; uses declare's default."
|
|
963
|
+
),
|
|
964
|
+
) -> None:
|
|
965
|
+
try:
|
|
966
|
+
changes = []
|
|
967
|
+
for entry in change:
|
|
968
|
+
path, separator, value = entry.partition("=")
|
|
969
|
+
if not separator or not path or not value:
|
|
970
|
+
raise EditRefusal("--change requires PATH=VALUE")
|
|
971
|
+
changes.append(
|
|
972
|
+
Change(path=path, value=value, source=source, witness=resolve_witness(witness))
|
|
973
|
+
)
|
|
974
|
+
document = plugins.pick("what_if", api.what_if)(contract, changes)
|
|
975
|
+
except (EditRefusal, UnsupportedSchemaVersion, OSError, YAMLError) as error:
|
|
976
|
+
_refused("what-if", error)
|
|
977
|
+
raise typer.Exit(2) from error
|
|
978
|
+
console.print_json(json.dumps(document))
|
|
979
|
+
|
|
980
|
+
|
|
981
|
+
@app.command(
|
|
982
|
+
"review",
|
|
983
|
+
help="Approve or question one already-declared value, with a note and a witness."
|
|
984
|
+
f"\n\n{exit_code_help('review')}",
|
|
985
|
+
)
|
|
986
|
+
def review_cmd(
|
|
987
|
+
ctx: typer.Context,
|
|
988
|
+
contract: Path = typer.Argument(..., help="Contract file to edit."),
|
|
989
|
+
path: str = typer.Argument(
|
|
990
|
+
..., help="'/'-separated contract path of the already-declared fact to review."
|
|
991
|
+
),
|
|
992
|
+
approve: bool = typer.Option(False, "--approve", help="Mark the value approved."),
|
|
993
|
+
question: bool = typer.Option(False, "--question", help="Mark the value questioned."),
|
|
994
|
+
note: str | None = typer.Option(
|
|
995
|
+
None, "--note", help="The reviewer's note (required for --question)."
|
|
996
|
+
),
|
|
997
|
+
witness: str = typer.Option(..., "--witness", help="Who is reviewing it (required)."),
|
|
998
|
+
locked_at: str | None = typer.Option(
|
|
999
|
+
None, "--locked-at", help="ISO date to stamp instead of today (tests only)."
|
|
1000
|
+
),
|
|
1001
|
+
dry_run: bool = typer.Option(False, "--dry-run", help="Print the diff, write nothing."),
|
|
1002
|
+
) -> None:
|
|
1003
|
+
"""Approve or question one declared value, appending the reviewer's note
|
|
1004
|
+
into the file. Exactly one of --approve/--question is required; a
|
|
1005
|
+
questioned review needs a --note — that gate, and the
|
|
1006
|
+
blank/"you"/assistant-name witness refusal, live in ``edit.apply_review``,
|
|
1007
|
+
the same gate every review door shares.
|
|
1008
|
+
"""
|
|
1009
|
+
if not contract.exists() or not contract.is_file():
|
|
1010
|
+
console.print(f"[red]review failed:[/] no contract at {contract}")
|
|
1011
|
+
raise typer.Exit(2)
|
|
1012
|
+
if approve == question:
|
|
1013
|
+
console.print("[red]review failed:[/] exactly one of --approve or --question is required")
|
|
1014
|
+
raise typer.Exit(2)
|
|
1015
|
+
state = "approved" if approve else "questioned"
|
|
1016
|
+
try:
|
|
1017
|
+
result = apply_review(
|
|
1018
|
+
contract,
|
|
1019
|
+
path,
|
|
1020
|
+
state,
|
|
1021
|
+
witness=witness,
|
|
1022
|
+
note=note,
|
|
1023
|
+
locked_at=_parse_locked_at(locked_at),
|
|
1024
|
+
dry_run=dry_run,
|
|
1025
|
+
)
|
|
1026
|
+
except (EditRefusal, UnsupportedSchemaVersion, OSError, YAMLError) as error:
|
|
1027
|
+
_refused("review", error)
|
|
1028
|
+
raise typer.Exit(2) from error
|
|
1029
|
+
_print_edit_result(result)
|
|
1030
|
+
console.print(
|
|
1031
|
+
"ran: " + build_cli_line("lockwire", "review", *argv_from_context(ctx)), soft_wrap=True
|
|
1032
|
+
)
|
|
1033
|
+
|
|
1034
|
+
|
|
1035
|
+
def _pose_triple(raw: str, flag: str) -> tuple[float, float, float]:
|
|
1036
|
+
"""Parse ``x,y,z`` (commas or spaces) into three floats, or refuse legibly."""
|
|
1037
|
+
parts = [p for p in raw.replace(",", " ").split() if p]
|
|
1038
|
+
if len(parts) != 3:
|
|
1039
|
+
console.print(
|
|
1040
|
+
f"[red]refused:[/] {flag} needs three numbers, e.g. {flag} 0.010,0.000,0.025 "
|
|
1041
|
+
f"— got {raw!r}"
|
|
1042
|
+
)
|
|
1043
|
+
raise typer.Exit(2)
|
|
1044
|
+
try:
|
|
1045
|
+
return (float(parts[0]), float(parts[1]), float(parts[2]))
|
|
1046
|
+
except ValueError:
|
|
1047
|
+
console.print(f"[red]refused:[/] {flag} {raw!r} is not three numbers")
|
|
1048
|
+
raise typer.Exit(2) from None
|
|
1049
|
+
|
|
1050
|
+
|
|
1051
|
+
def _run_pose_edit(ctx: typer.Context, name: str, contract: Path, call) -> None:
|
|
1052
|
+
"""Shared existence, refusal and replay handling for pose and mount edits."""
|
|
1053
|
+
if not contract.exists() or not contract.is_file():
|
|
1054
|
+
console.print(f"[red]{name} failed:[/] no contract at {contract}")
|
|
1055
|
+
raise typer.Exit(2)
|
|
1056
|
+
try:
|
|
1057
|
+
result = call()
|
|
1058
|
+
except (EditRefusal, UnsupportedSchemaVersion, OSError, YAMLError) as error:
|
|
1059
|
+
_refused(name, error)
|
|
1060
|
+
raise typer.Exit(2) from error
|
|
1061
|
+
_print_edit_result(result)
|
|
1062
|
+
console.print(
|
|
1063
|
+
"ran: " + build_cli_line("lockwire", name, *argv_from_context(ctx)), soft_wrap=True
|
|
1064
|
+
)
|
|
1065
|
+
|
|
1066
|
+
|
|
1067
|
+
@app.command(
|
|
1068
|
+
"place",
|
|
1069
|
+
help="Place a device on a model link, writing its frame and offset together."
|
|
1070
|
+
f"\n\n{exit_code_help('place')}",
|
|
1071
|
+
)
|
|
1072
|
+
def place_cmd(
|
|
1073
|
+
ctx: typer.Context,
|
|
1074
|
+
contract: Path = typer.Argument(..., help="Contract file to edit."),
|
|
1075
|
+
target: str = typer.Argument(..., help="Existing device id."),
|
|
1076
|
+
on: str = typer.Option(..., "--on", help="Model link that holds the device."),
|
|
1077
|
+
offset: str = typer.Option(..., "--offset", help="x,y,z in --unit."),
|
|
1078
|
+
unit: str | None = typer.Option(None, "--unit", help="Length unit (required)."),
|
|
1079
|
+
source: str = typer.Option(..., "--source", help="Where the placement comes from."),
|
|
1080
|
+
witness: str = typer.Option(..., "--witness", help="Who placed it."),
|
|
1081
|
+
note: str | None = typer.Option(None, "--note"),
|
|
1082
|
+
locked_at: str | None = typer.Option(None, "--locked-at", help="ISO date (tests only)."),
|
|
1083
|
+
dry_run: bool = typer.Option(False, "--dry-run", help="Print the diff, write nothing."),
|
|
1084
|
+
) -> None:
|
|
1085
|
+
"""Place a device by writing mount_frame and mount_xyz in one transaction."""
|
|
1086
|
+
triple = _pose_triple(offset, "--offset")
|
|
1087
|
+
_run_pose_edit(
|
|
1088
|
+
ctx,
|
|
1089
|
+
"place",
|
|
1090
|
+
contract,
|
|
1091
|
+
lambda: edit_place(
|
|
1092
|
+
contract,
|
|
1093
|
+
target,
|
|
1094
|
+
on,
|
|
1095
|
+
triple,
|
|
1096
|
+
unit=unit,
|
|
1097
|
+
source=source,
|
|
1098
|
+
witness=witness,
|
|
1099
|
+
note=note,
|
|
1100
|
+
locked_at=_parse_locked_at(locked_at),
|
|
1101
|
+
dry_run=dry_run,
|
|
1102
|
+
),
|
|
1103
|
+
)
|
|
1104
|
+
|
|
1105
|
+
|
|
1106
|
+
@app.command(
|
|
1107
|
+
"mount-shape",
|
|
1108
|
+
help="Set a device's plate, bracket, tube or custom-mesh mount geometry."
|
|
1109
|
+
f"\n\n{exit_code_help('mount-shape')}",
|
|
1110
|
+
)
|
|
1111
|
+
def mount_shape_cmd(
|
|
1112
|
+
ctx: typer.Context,
|
|
1113
|
+
contract: Path = typer.Argument(..., help="Contract file to edit."),
|
|
1114
|
+
target: str = typer.Argument(..., help="Existing device id."),
|
|
1115
|
+
kind: str = typer.Argument(..., help="plate | bracket | tube | custom-mesh"),
|
|
1116
|
+
dimensions_mm: str = typer.Option(..., "--dimensions-mm", help="Three positive dimensions."),
|
|
1117
|
+
fastener_pattern: str | None = typer.Option(None, "--fastener-pattern"),
|
|
1118
|
+
mesh_path: str | None = typer.Option(None, "--mesh-path"),
|
|
1119
|
+
source: str = typer.Option(..., "--source", help="Where the geometry comes from."),
|
|
1120
|
+
witness: str = typer.Option(..., "--witness", help="Who declared it."),
|
|
1121
|
+
note: str | None = typer.Option(None, "--note"),
|
|
1122
|
+
locked_at: str | None = typer.Option(None, "--locked-at", help="ISO date (tests only)."),
|
|
1123
|
+
dry_run: bool = typer.Option(False, "--dry-run", help="Print the diff, write nothing."),
|
|
1124
|
+
) -> None:
|
|
1125
|
+
"""Write one mount_shape record through the same contract writer as declare."""
|
|
1126
|
+
dimensions = _pose_triple(dimensions_mm, "--dimensions-mm")
|
|
1127
|
+
shape = {
|
|
1128
|
+
"kind": kind,
|
|
1129
|
+
"dimensions_mm": list(dimensions),
|
|
1130
|
+
**({"fastener_pattern": fastener_pattern} if fastener_pattern is not None else {}),
|
|
1131
|
+
**({"mesh_path": mesh_path} if mesh_path is not None else {}),
|
|
1132
|
+
}
|
|
1133
|
+
_run_pose_edit(
|
|
1134
|
+
ctx,
|
|
1135
|
+
"mount-shape",
|
|
1136
|
+
contract,
|
|
1137
|
+
lambda: edit_mount_shape(
|
|
1138
|
+
contract,
|
|
1139
|
+
target,
|
|
1140
|
+
shape,
|
|
1141
|
+
source=source,
|
|
1142
|
+
witness=witness,
|
|
1143
|
+
note=note,
|
|
1144
|
+
locked_at=_parse_locked_at(locked_at),
|
|
1145
|
+
dry_run=dry_run,
|
|
1146
|
+
),
|
|
1147
|
+
)
|
|
1148
|
+
|
|
1149
|
+
|
|
1150
|
+
@app.command(
|
|
1151
|
+
"move",
|
|
1152
|
+
help="Move a device or link: set its offset in the frame that already holds it."
|
|
1153
|
+
f"\n\n{exit_code_help('move')}",
|
|
1154
|
+
)
|
|
1155
|
+
def move_cmd(
|
|
1156
|
+
ctx: typer.Context,
|
|
1157
|
+
contract: Path = typer.Argument(..., help="Contract file to edit."),
|
|
1158
|
+
target: str = typer.Argument(..., help="Device id, or a link name in the recorded model."),
|
|
1159
|
+
offset: str = typer.Option(..., "--offset", help="x,y,z in --unit, e.g. 0.010,0,0.025."),
|
|
1160
|
+
unit: str | None = typer.Option(None, "--unit", help="Length unit: m, cm or mm (required)."),
|
|
1161
|
+
source: str = typer.Option(..., "--source", help="Where the fact comes from (required)."),
|
|
1162
|
+
witness: str = typer.Option(..., "--witness", help="Who measured it (required)."),
|
|
1163
|
+
note: str | None = typer.Option(None, "--note", help="Appended to the owning block's note."),
|
|
1164
|
+
locked_at: str | None = typer.Option(
|
|
1165
|
+
None, "--locked-at", help="ISO date to stamp instead of today (tests only)."
|
|
1166
|
+
),
|
|
1167
|
+
dry_run: bool = typer.Option(False, "--dry-run", help="Print the diff, write nothing."),
|
|
1168
|
+
) -> None:
|
|
1169
|
+
"""Move a device in its mount frame, or a link in its parent link's frame.
|
|
1170
|
+
|
|
1171
|
+
Writes `devices/<id>/physical/mount_xyz` for a device and the placing joint's
|
|
1172
|
+
`origin/xyz` for a link, through the same writer `declare` uses.
|
|
1173
|
+
"""
|
|
1174
|
+
triple = _pose_triple(offset, "--offset")
|
|
1175
|
+
_run_pose_edit(
|
|
1176
|
+
ctx,
|
|
1177
|
+
"move",
|
|
1178
|
+
contract,
|
|
1179
|
+
lambda: edit_move(
|
|
1180
|
+
contract,
|
|
1181
|
+
target,
|
|
1182
|
+
triple,
|
|
1183
|
+
unit=unit,
|
|
1184
|
+
source=source,
|
|
1185
|
+
witness=witness,
|
|
1186
|
+
note=note,
|
|
1187
|
+
locked_at=_parse_locked_at(locked_at),
|
|
1188
|
+
dry_run=dry_run,
|
|
1189
|
+
),
|
|
1190
|
+
)
|
|
1191
|
+
|
|
1192
|
+
|
|
1193
|
+
@app.command(
|
|
1194
|
+
"rotate",
|
|
1195
|
+
help="Rotate a link: set its placing joint's orientation, unit required."
|
|
1196
|
+
f"\n\n{exit_code_help('rotate')}",
|
|
1197
|
+
)
|
|
1198
|
+
def rotate_cmd(
|
|
1199
|
+
ctx: typer.Context,
|
|
1200
|
+
contract: Path = typer.Argument(..., help="Contract file to edit."),
|
|
1201
|
+
target: str = typer.Argument(..., help="Link name in the recorded model."),
|
|
1202
|
+
rpy: str = typer.Option(..., "--rpy", help="roll,pitch,yaw in --unit, e.g. 0,0,1.5708."),
|
|
1203
|
+
unit: str | None = typer.Option(None, "--unit", help="Angle unit: rad or deg (required)."),
|
|
1204
|
+
source: str = typer.Option(..., "--source", help="Where the fact comes from (required)."),
|
|
1205
|
+
witness: str = typer.Option(..., "--witness", help="Who measured it (required)."),
|
|
1206
|
+
note: str | None = typer.Option(None, "--note", help="Appended to the owning block's note."),
|
|
1207
|
+
locked_at: str | None = typer.Option(
|
|
1208
|
+
None, "--locked-at", help="ISO date to stamp instead of today (tests only)."
|
|
1209
|
+
),
|
|
1210
|
+
dry_run: bool = typer.Option(False, "--dry-run", help="Print the diff, write nothing."),
|
|
1211
|
+
) -> None:
|
|
1212
|
+
"""Set a link's orientation in its parent's frame, written in radians.
|
|
1213
|
+
|
|
1214
|
+
A device target is refused: `schema.Physical` records no orientation and the
|
|
1215
|
+
schema is frozen. A missing `--unit` is refused, never defaulted.
|
|
1216
|
+
"""
|
|
1217
|
+
triple = _pose_triple(rpy, "--rpy")
|
|
1218
|
+
_run_pose_edit(
|
|
1219
|
+
ctx,
|
|
1220
|
+
"rotate",
|
|
1221
|
+
contract,
|
|
1222
|
+
lambda: edit_rotate(
|
|
1223
|
+
contract,
|
|
1224
|
+
target,
|
|
1225
|
+
triple,
|
|
1226
|
+
unit=unit,
|
|
1227
|
+
source=source,
|
|
1228
|
+
witness=witness,
|
|
1229
|
+
note=note,
|
|
1230
|
+
locked_at=_parse_locked_at(locked_at),
|
|
1231
|
+
dry_run=dry_run,
|
|
1232
|
+
),
|
|
1233
|
+
)
|
|
1234
|
+
|
|
1235
|
+
|
|
1236
|
+
@app.command(
|
|
1237
|
+
"reparent",
|
|
1238
|
+
help="Re-parent a device's mount frame or a link's joint parent."
|
|
1239
|
+
f"\n\n{exit_code_help('reparent')}",
|
|
1240
|
+
)
|
|
1241
|
+
def reparent_cmd(
|
|
1242
|
+
ctx: typer.Context,
|
|
1243
|
+
contract: Path = typer.Argument(..., help="Contract file to edit."),
|
|
1244
|
+
target: str = typer.Argument(..., help="Device id, or a link name in the recorded model."),
|
|
1245
|
+
to: str = typer.Option(..., "--to", help="The link it should hang off instead."),
|
|
1246
|
+
source: str = typer.Option(..., "--source", help="Where the fact comes from (required)."),
|
|
1247
|
+
witness: str = typer.Option(..., "--witness", help="Who decided it (required)."),
|
|
1248
|
+
note: str | None = typer.Option(None, "--note", help="Appended to the owning block's note."),
|
|
1249
|
+
locked_at: str | None = typer.Option(
|
|
1250
|
+
None, "--locked-at", help="ISO date to stamp instead of today (tests only)."
|
|
1251
|
+
),
|
|
1252
|
+
dry_run: bool = typer.Option(False, "--dry-run", help="Print the diff, write nothing."),
|
|
1253
|
+
) -> None:
|
|
1254
|
+
"""Change what a device is mounted to, or what a link hangs off.
|
|
1255
|
+
|
|
1256
|
+
Refuses, naming which: a parent that does not resolve, a re-parent that closes
|
|
1257
|
+
a cycle, and one that would orphan the moved subtree. A link re-parent moves
|
|
1258
|
+
`structure_hash`, and the new hash is printed rather than moved silently.
|
|
1259
|
+
"""
|
|
1260
|
+
_run_pose_edit(
|
|
1261
|
+
ctx,
|
|
1262
|
+
"reparent",
|
|
1263
|
+
contract,
|
|
1264
|
+
lambda: edit_reparent(
|
|
1265
|
+
contract,
|
|
1266
|
+
target,
|
|
1267
|
+
to,
|
|
1268
|
+
source=source,
|
|
1269
|
+
witness=witness,
|
|
1270
|
+
note=note,
|
|
1271
|
+
locked_at=_parse_locked_at(locked_at),
|
|
1272
|
+
dry_run=dry_run,
|
|
1273
|
+
),
|
|
1274
|
+
)
|
|
1275
|
+
|
|
1276
|
+
|
|
1277
|
+
@app.command("apply")
|
|
1278
|
+
def apply_cmd(
|
|
1279
|
+
ctx: typer.Context,
|
|
1280
|
+
contract: Path = typer.Argument(..., help="Contract file to edit."),
|
|
1281
|
+
changes_file: Path = typer.Argument(
|
|
1282
|
+
..., help="YAML list of {path, value, source, witness, note?}."
|
|
1283
|
+
),
|
|
1284
|
+
dry_run: bool = typer.Option(
|
|
1285
|
+
False, "--dry-run", help="Print the combined diff, write nothing."
|
|
1286
|
+
),
|
|
1287
|
+
) -> None:
|
|
1288
|
+
"""Apply many declared facts from one change file, atomically.
|
|
1289
|
+
|
|
1290
|
+
All entries validate before anything is written; a bad entry refuses the
|
|
1291
|
+
whole apply and names the failing entry (index + path). Exit 0 on
|
|
1292
|
+
success, 2 on refusal.
|
|
1293
|
+
"""
|
|
1294
|
+
if not contract.exists() or not contract.is_file():
|
|
1295
|
+
console.print(f"[red]apply failed:[/] no contract at {contract}")
|
|
1296
|
+
raise typer.Exit(2)
|
|
1297
|
+
if not changes_file.exists() or not changes_file.is_file():
|
|
1298
|
+
console.print(f"[red]apply failed:[/] no changes file at {changes_file}")
|
|
1299
|
+
raise typer.Exit(2)
|
|
1300
|
+
try:
|
|
1301
|
+
with open(changes_file, encoding="utf-8") as handle:
|
|
1302
|
+
rows = YAML().load(handle) or []
|
|
1303
|
+
if not isinstance(rows, list):
|
|
1304
|
+
raise EditRefusal(f"{changes_file} must contain a YAML list of changes")
|
|
1305
|
+
changes = []
|
|
1306
|
+
for index, row in enumerate(rows, start=1):
|
|
1307
|
+
if not isinstance(row, dict):
|
|
1308
|
+
raise EditRefusal(f"entry {index}: must be a mapping")
|
|
1309
|
+
missing = [k for k in ("path", "value", "source", "witness") if k not in row]
|
|
1310
|
+
if missing:
|
|
1311
|
+
raise EditRefusal(f"entry {index}: missing {', '.join(missing)}")
|
|
1312
|
+
changes.append(
|
|
1313
|
+
Change(
|
|
1314
|
+
path=str(row["path"]),
|
|
1315
|
+
value=row["value"],
|
|
1316
|
+
source=str(row["source"]),
|
|
1317
|
+
witness=str(row["witness"]),
|
|
1318
|
+
note=row.get("note"),
|
|
1319
|
+
unit=row.get("unit"),
|
|
1320
|
+
)
|
|
1321
|
+
)
|
|
1322
|
+
result = apply_changes(contract, changes, dry_run=dry_run)
|
|
1323
|
+
except (EditRefusal, UnsupportedSchemaVersion, OSError, YAMLError) as error:
|
|
1324
|
+
_refused("apply", error)
|
|
1325
|
+
raise typer.Exit(2) from error
|
|
1326
|
+
_print_edit_result(result)
|
|
1327
|
+
console.print(
|
|
1328
|
+
"ran: " + build_cli_line("lockwire", "apply", *argv_from_context(ctx)), soft_wrap=True
|
|
1329
|
+
)
|
|
1330
|
+
|
|
1331
|
+
|
|
1332
|
+
@app.command("add")
|
|
1333
|
+
def add_cmd(
|
|
1334
|
+
ctx: typer.Context,
|
|
1335
|
+
kind: str = typer.Argument(..., help="device | rail | bus | family | model | budget | battery"),
|
|
1336
|
+
contract: Path = typer.Argument(..., help="Contract file to edit."),
|
|
1337
|
+
id: str | None = typer.Option(None, "--id", help="New entry's id (not used for battery)."),
|
|
1338
|
+
source: str = typer.Option(..., "--source", help="Where every field comes from (required)."),
|
|
1339
|
+
witness: str = typer.Option(..., "--witness", help="Who/what declared it (required)."),
|
|
1340
|
+
note: str | None = typer.Option(None, "--note"),
|
|
1341
|
+
model: str | None = typer.Option(None, "--model", help="device: -> DeviceModel.id."),
|
|
1342
|
+
owner: str | None = typer.Option(
|
|
1343
|
+
None, "--owner", help="device/budget: owning team (both required)."
|
|
1344
|
+
),
|
|
1345
|
+
mass: str | None = typer.Option(None, "--mass", help="device: physical.mass, e.g. '25 g'."),
|
|
1346
|
+
mount_frame: str | None = typer.Option(
|
|
1347
|
+
None, "--mount-frame", help="device: physical.mount_frame."
|
|
1348
|
+
),
|
|
1349
|
+
rail: str | None = typer.Option(
|
|
1350
|
+
None, "--rail", help="device: power.rail id. budget: rail_power's Rail.id."
|
|
1351
|
+
),
|
|
1352
|
+
draw: str | None = typer.Option(None, "--draw", help="device: power.draw, e.g. '5 W'."),
|
|
1353
|
+
bus_ref: str | None = typer.Option(None, "--bus-ref", help="device: identity.bus_ref id."),
|
|
1354
|
+
host: str | None = typer.Option(None, "--host", help="device/bus: host id."),
|
|
1355
|
+
voltage: str | None = typer.Option(None, "--voltage", help="rail: voltage, e.g. '5.2 V'."),
|
|
1356
|
+
capacity: str | None = typer.Option(None, "--capacity", help="rail: capacity, e.g. '15.6 W'."),
|
|
1357
|
+
upstream: str | None = typer.Option(None, "--upstream", help="rail: upstream Rail.id."),
|
|
1358
|
+
efficiency: str | None = typer.Option(None, "--efficiency", help="rail: converter efficiency."),
|
|
1359
|
+
bus_kind: str | None = typer.Option(
|
|
1360
|
+
None, "--bus-kind", help="bus: usb|ethernet|serial|can|i2c|spi."
|
|
1361
|
+
),
|
|
1362
|
+
bitrate: str | None = typer.Option(None, "--bitrate", help="bus: raw bits/s."),
|
|
1363
|
+
name: str | None = typer.Option(
|
|
1364
|
+
None,
|
|
1365
|
+
"--name",
|
|
1366
|
+
help="family: DeviceFamily.name (required). device: names a family this device "
|
|
1367
|
+
"creates via --family, when --family is new (optional — defaults from the id).",
|
|
1368
|
+
),
|
|
1369
|
+
min_capture_window_s: str | None = typer.Option(
|
|
1370
|
+
None, "--min-capture-window-s", help="family: seconds, e.g. '60'."
|
|
1371
|
+
),
|
|
1372
|
+
notes: str | None = typer.Option(None, "--notes", help="family: DeviceFamily.notes."),
|
|
1373
|
+
family: str | None = typer.Option(
|
|
1374
|
+
None,
|
|
1375
|
+
"--family",
|
|
1376
|
+
help="model: -> DeviceFamily.id. device: -> DeviceFamily.id, created with --model "
|
|
1377
|
+
"if either is new.",
|
|
1378
|
+
),
|
|
1379
|
+
input_voltage_min: str | None = typer.Option(
|
|
1380
|
+
None, "--input-voltage-min", help="model: input_voltage_range.min, e.g. '5.0 V'."
|
|
1381
|
+
),
|
|
1382
|
+
input_voltage_max: str | None = typer.Option(
|
|
1383
|
+
None, "--input-voltage-max", help="model: input_voltage_range.max, e.g. '5.5 V'."
|
|
1384
|
+
),
|
|
1385
|
+
budget_kind: str | None = typer.Option(
|
|
1386
|
+
None, "--kind", help="budget: rail_power|endurance|mass_total (required)."
|
|
1387
|
+
),
|
|
1388
|
+
limit: str | None = typer.Option(None, "--limit", help="budget: limit, e.g. '15.6 W'."),
|
|
1389
|
+
draw_mode: str | None = typer.Option(
|
|
1390
|
+
None, "--draw-mode", help="budget: named power mode this limit applies to."
|
|
1391
|
+
),
|
|
1392
|
+
sources: str | None = typer.Option(
|
|
1393
|
+
None, "--sources", help="budget (mass_total only): devices|links|links+devices."
|
|
1394
|
+
),
|
|
1395
|
+
nominal_voltage: str | None = typer.Option(
|
|
1396
|
+
None, "--nominal-voltage", help="battery: voltage, e.g. '25.9 V'."
|
|
1397
|
+
),
|
|
1398
|
+
chemistry: str | None = typer.Option(
|
|
1399
|
+
None,
|
|
1400
|
+
"--chemistry",
|
|
1401
|
+
help="battery: chemistry, e.g. 'Li-ion' (no schema field — folded into a comment).",
|
|
1402
|
+
),
|
|
1403
|
+
dry_run: bool = typer.Option(False, "--dry-run", help="Print the diff, write nothing."),
|
|
1404
|
+
) -> None:
|
|
1405
|
+
"""Add a new device/rail/bus/family/model/budget/battery block, with provenance.
|
|
1406
|
+
|
|
1407
|
+
Note (F2, model): `DeviceModel` carries no schema field for a human name —
|
|
1408
|
+
only `hard_limits`, `actuator`, `thermal_limit`, `radio` and `match` beyond
|
|
1409
|
+
what this command exposes, all sparse/nested datasheet shapes; author them
|
|
1410
|
+
with `declare`/`apply` for now, the same way `scene import` writes
|
|
1411
|
+
structured facts.
|
|
1412
|
+
|
|
1413
|
+
Note (battery): `Battery` (schema.py) has no `provenance` field and
|
|
1414
|
+
forbids extras — its source/witness/chemistry are recorded as a YAML
|
|
1415
|
+
comment on the `battery:` key, not a structured block; `check`/`describe`
|
|
1416
|
+
never read it back. `battery` creates or updates the singleton block
|
|
1417
|
+
(there is no id, and no duplicate refusal).
|
|
1418
|
+
|
|
1419
|
+
One datasheet, one submit: ``add device`` with both --family and
|
|
1420
|
+
--model creates the family and/or model when they do not already exist,
|
|
1421
|
+
the device, and all three in one write — one witness line for the whole
|
|
1422
|
+
submit (a family/model created this way carries a provenance that points
|
|
1423
|
+
at the device instead of repeating the witness's name). --model alone,
|
|
1424
|
+
with no --family, keeps the old behaviour: refuses an unknown model.
|
|
1425
|
+
|
|
1426
|
+
Refuses a duplicate id, an unknown rail/bus/family/model reference, or a
|
|
1427
|
+
value the schema rejects; nothing is written on refusal. Exit 0 on
|
|
1428
|
+
success, 2 on refusal.
|
|
1429
|
+
"""
|
|
1430
|
+
if not contract.exists() or not contract.is_file():
|
|
1431
|
+
console.print(f"[red]add failed:[/] no contract at {contract}")
|
|
1432
|
+
raise typer.Exit(2)
|
|
1433
|
+
fields = {
|
|
1434
|
+
"id": id,
|
|
1435
|
+
"model": model,
|
|
1436
|
+
"owner": owner,
|
|
1437
|
+
"physical_mass": mass,
|
|
1438
|
+
"physical_mount_frame": mount_frame,
|
|
1439
|
+
"power_rail": rail,
|
|
1440
|
+
"power_draw": draw,
|
|
1441
|
+
"identity_bus_ref": bus_ref,
|
|
1442
|
+
"identity_host": host,
|
|
1443
|
+
"voltage": voltage,
|
|
1444
|
+
"capacity": capacity,
|
|
1445
|
+
"upstream": upstream,
|
|
1446
|
+
"efficiency": efficiency,
|
|
1447
|
+
"kind": bus_kind,
|
|
1448
|
+
"host": host,
|
|
1449
|
+
"bitrate": bitrate,
|
|
1450
|
+
"name": name,
|
|
1451
|
+
"min_capture_window_s": min_capture_window_s,
|
|
1452
|
+
"notes": notes,
|
|
1453
|
+
"family": family,
|
|
1454
|
+
"input_voltage_min": input_voltage_min,
|
|
1455
|
+
"input_voltage_max": input_voltage_max,
|
|
1456
|
+
"budget_kind": budget_kind,
|
|
1457
|
+
"limit": limit,
|
|
1458
|
+
"draw_mode": draw_mode,
|
|
1459
|
+
"sources": sources,
|
|
1460
|
+
"rail": rail,
|
|
1461
|
+
"nominal_voltage": nominal_voltage,
|
|
1462
|
+
"chemistry": chemistry,
|
|
1463
|
+
}
|
|
1464
|
+
try:
|
|
1465
|
+
result = add_entry(
|
|
1466
|
+
contract, kind, fields, source=source, witness=witness, note=note, dry_run=dry_run
|
|
1467
|
+
)
|
|
1468
|
+
except (EditRefusal, UnsupportedSchemaVersion, OSError, YAMLError) as error:
|
|
1469
|
+
_refused("add", error)
|
|
1470
|
+
raise typer.Exit(2) from error
|
|
1471
|
+
_print_edit_result(result)
|
|
1472
|
+
console.print(
|
|
1473
|
+
"ran: " + build_cli_line("lockwire", "add", *argv_from_context(ctx)), soft_wrap=True
|
|
1474
|
+
)
|
|
1475
|
+
|
|
1476
|
+
|
|
1477
|
+
@app.command("gearbox")
|
|
1478
|
+
def gearbox_cmd(
|
|
1479
|
+
ctx: typer.Context,
|
|
1480
|
+
contract: Path = typer.Argument(..., help="Contract file to edit."),
|
|
1481
|
+
id: str = typer.Option(..., "--id", help="New gearbox id."),
|
|
1482
|
+
ratio: str = typer.Option(..., "--ratio", help="Reduction ratio; must be > 0."),
|
|
1483
|
+
input_joint: str = typer.Option(..., "--input-joint", help="Input model joint name."),
|
|
1484
|
+
output_joint: str = typer.Option(..., "--output-joint", help="Output model joint name."),
|
|
1485
|
+
source: str = typer.Option(..., "--source", help="Where the gearbox data comes from."),
|
|
1486
|
+
witness: str = typer.Option(..., "--witness", help="Who declared it."),
|
|
1487
|
+
note: str | None = typer.Option(None, "--note"),
|
|
1488
|
+
dry_run: bool = typer.Option(False, "--dry-run", help="Print the diff, write nothing."),
|
|
1489
|
+
) -> None:
|
|
1490
|
+
"""Add a gearbox template with provenance to a contract."""
|
|
1491
|
+
if not contract.exists() or not contract.is_file():
|
|
1492
|
+
console.print(f"[red]gearbox failed:[/] no contract at {contract}")
|
|
1493
|
+
raise typer.Exit(2)
|
|
1494
|
+
try:
|
|
1495
|
+
result = add_entry(
|
|
1496
|
+
contract,
|
|
1497
|
+
"gearbox",
|
|
1498
|
+
{"id": id, "ratio": ratio, "input_joint": input_joint, "output_joint": output_joint},
|
|
1499
|
+
source=source,
|
|
1500
|
+
witness=witness,
|
|
1501
|
+
note=note,
|
|
1502
|
+
dry_run=dry_run,
|
|
1503
|
+
)
|
|
1504
|
+
except (EditRefusal, UnsupportedSchemaVersion, OSError, YAMLError) as error:
|
|
1505
|
+
_refused("gearbox", error)
|
|
1506
|
+
raise typer.Exit(2) from error
|
|
1507
|
+
_print_edit_result(result)
|
|
1508
|
+
console.print(
|
|
1509
|
+
"ran: " + build_cli_line("lockwire", "gearbox", *argv_from_context(ctx)),
|
|
1510
|
+
soft_wrap=True,
|
|
1511
|
+
)
|
|
1512
|
+
|
|
1513
|
+
|
|
1514
|
+
@app.command("mcp")
|
|
1515
|
+
def mcp_cmd() -> None:
|
|
1516
|
+
"""Serve lockwire's checks, descriptions, and plan validation to an agent over MCP (stdio)."""
|
|
1517
|
+
try:
|
|
1518
|
+
from lockwire.mcp_server import build_server
|
|
1519
|
+
except ImportError:
|
|
1520
|
+
import sys
|
|
1521
|
+
|
|
1522
|
+
print("install the mcp extra: uv sync --extra mcp", file=sys.stderr)
|
|
1523
|
+
raise typer.Exit(2) from None
|
|
1524
|
+
build_server().run("stdio")
|
|
1525
|
+
|
|
1526
|
+
|
|
1527
|
+
def _diff_side(
|
|
1528
|
+
spec: str, contract: Path, repo: Path | None, *, exit_code: bool, side: str
|
|
1529
|
+
) -> Contract:
|
|
1530
|
+
"""One side of `lockwire diff`: a contract file on disk, or the contract at a git ref.
|
|
1531
|
+
|
|
1532
|
+
A spec naming an existing file is read from disk and never touches git, so
|
|
1533
|
+
`diff a.yaml b.yaml` works outside a repository. Anything else is a ref,
|
|
1534
|
+
and every ref question goes through `gitref` (the one git door).
|
|
1535
|
+
|
|
1536
|
+
Both branches load through ``_load_contract_lib`` directly (not the
|
|
1537
|
+
module-global ``load_contract`` wrapper) so an unsupported schema version
|
|
1538
|
+
is caught in exactly one place below, whichever side it came from, and the
|
|
1539
|
+
refusal names ``side`` and ``spec`` — either side
|
|
1540
|
+
declaring a schema version this lockwire can't read is REFUSED, and the
|
|
1541
|
+
message says which side.
|
|
1542
|
+
"""
|
|
1543
|
+
on_disk = Path(spec)
|
|
1544
|
+
text: str | None = None
|
|
1545
|
+
if not (on_disk.exists() and on_disk.is_file()):
|
|
1546
|
+
if repo is None:
|
|
1547
|
+
console.print("not a git repository — nothing to compare against")
|
|
1548
|
+
raise typer.Exit(0)
|
|
1549
|
+
if gitref.rev_parse(spec, repo=repo) is None:
|
|
1550
|
+
console.print(f"[red]no such ref[/] {spec!r}")
|
|
1551
|
+
raise typer.Exit(2)
|
|
1552
|
+
relpath = gitref.relpath_in_repo(contract, repo)
|
|
1553
|
+
if relpath is None:
|
|
1554
|
+
console.print(f"[red]{contract} is outside the repository at {repo}[/]")
|
|
1555
|
+
raise typer.Exit(2)
|
|
1556
|
+
text = gitref.show(spec, relpath, repo=repo)
|
|
1557
|
+
if text is None:
|
|
1558
|
+
console.print(f"{contract.name} did not exist at {spec}")
|
|
1559
|
+
raise typer.Exit(1 if exit_code else 0)
|
|
1560
|
+
try:
|
|
1561
|
+
return load_contract_text(text) if text is not None else _load_contract_lib(on_disk)
|
|
1562
|
+
except UnsupportedSchemaVersion as error:
|
|
1563
|
+
console.print(f"[bold red]REFUSED[/] — side {side} ({spec!r}): {error}")
|
|
1564
|
+
raise typer.Exit(2) from error
|
|
1565
|
+
except (OSError, TypeError, ValueError, YAMLError) as error:
|
|
1566
|
+
console.print(f"[red]diff failed:[/] {error}")
|
|
1567
|
+
raise typer.Exit(2) from error
|
|
1568
|
+
|
|
1569
|
+
|
|
1570
|
+
@app.command("diff")
|
|
1571
|
+
def diff_cmd(
|
|
1572
|
+
a: str = typer.Argument("HEAD", help="Ref (or contract path) to compare from. Default: HEAD."),
|
|
1573
|
+
b: str | None = typer.Argument(
|
|
1574
|
+
None, help="Ref (or contract path) to compare to. Default: the file on disk."
|
|
1575
|
+
),
|
|
1576
|
+
contract: Path = typer.Option(
|
|
1577
|
+
DEFAULT_CONTRACT, "--file", "-f", help="Contract file the refs are resolved against."
|
|
1578
|
+
),
|
|
1579
|
+
json_output: bool = typer.Option(False, "--json", help="Emit the fact diff as JSON."),
|
|
1580
|
+
exit_code: bool = typer.Option(
|
|
1581
|
+
False, "--exit-code", help="Exit 1 when anything changed, like `git diff --exit-code`."
|
|
1582
|
+
),
|
|
1583
|
+
) -> None:
|
|
1584
|
+
"""What changed in the facts — declared values, entries, verdict — not in the text."""
|
|
1585
|
+
if b is None and not contract.exists():
|
|
1586
|
+
console.print(f"[red]no contract at {contract}[/] — run `lockwire init <urdf>` first")
|
|
1587
|
+
raise typer.Exit(2)
|
|
1588
|
+
repo = gitref.repo_root(contract if contract.exists() else Path.cwd())
|
|
1589
|
+
before = _diff_side(a, contract, repo, exit_code=exit_code, side="a")
|
|
1590
|
+
after = _diff_side(
|
|
1591
|
+
b if b is not None else str(contract), contract, repo, exit_code=exit_code, side="b"
|
|
1592
|
+
)
|
|
1593
|
+
|
|
1594
|
+
diff = fact_diff(before, after)
|
|
1595
|
+
if json_output:
|
|
1596
|
+
typer.echo(json.dumps(diff.as_json(), indent=2))
|
|
1597
|
+
else:
|
|
1598
|
+
for line in render_lines(diff):
|
|
1599
|
+
# markup off: a provenance bracket in a value is text, not rich markup.
|
|
1600
|
+
console.print(line, markup=False, highlight=False)
|
|
1601
|
+
raise typer.Exit(1 if (exit_code and diff.changed) else 0)
|
|
1602
|
+
|
|
1603
|
+
|
|
1604
|
+
class _LogCommand(TyperCommand):
|
|
1605
|
+
def parse_args(self, ctx: typer.Context, args: list[str]) -> list[str]:
|
|
1606
|
+
ctx.meta["log_path_separator"] = "--" in args
|
|
1607
|
+
return super().parse_args(ctx, args)
|
|
1608
|
+
|
|
1609
|
+
|
|
1610
|
+
def _split_log_pathspec(
|
|
1611
|
+
tokens: list[str], *, path_separator: bool
|
|
1612
|
+
) -> tuple[str | None, str | None]:
|
|
1613
|
+
"""Split git's ``[<revision-range>] [-- <path>]`` shape for one contract."""
|
|
1614
|
+
if len(tokens) > 2:
|
|
1615
|
+
raise ValueError("log accepts one revision range and one contract path")
|
|
1616
|
+
if not tokens:
|
|
1617
|
+
if path_separator:
|
|
1618
|
+
raise ValueError("log requires a contract path after --")
|
|
1619
|
+
return None, None
|
|
1620
|
+
if not path_separator:
|
|
1621
|
+
if len(tokens) == 2:
|
|
1622
|
+
raise ValueError("separate the contract path with --")
|
|
1623
|
+
return tokens[0], None
|
|
1624
|
+
return (None, tokens[0]) if len(tokens) == 1 else (tokens[0], tokens[1])
|
|
1625
|
+
|
|
1626
|
+
|
|
1627
|
+
@app.command("log", cls=_LogCommand)
|
|
1628
|
+
def log_cmd(
|
|
1629
|
+
ctx: typer.Context,
|
|
1630
|
+
pathspec: list[str] = typer.Argument(
|
|
1631
|
+
None, help="[<revision-range>] [-- <path>]. Default: full history, the contract file."
|
|
1632
|
+
),
|
|
1633
|
+
json_output: bool = typer.Option(False, "--json", help="Emit each commit as a JSON object."),
|
|
1634
|
+
) -> None:
|
|
1635
|
+
"""Git history for the contract: who, when, verdict, and fact diff."""
|
|
1636
|
+
try:
|
|
1637
|
+
revision_range, path = _split_log_pathspec(
|
|
1638
|
+
pathspec or [], path_separator=bool(ctx.meta["log_path_separator"])
|
|
1639
|
+
)
|
|
1640
|
+
except ValueError as error:
|
|
1641
|
+
console.print(f"[red]{error}[/]")
|
|
1642
|
+
raise typer.Exit(2) from error
|
|
1643
|
+
contract = Path(path) if path is not None else DEFAULT_CONTRACT
|
|
1644
|
+
repo = gitref.repo_root(contract if contract.exists() else Path.cwd())
|
|
1645
|
+
if repo is None:
|
|
1646
|
+
console.print("not a git repository")
|
|
1647
|
+
raise typer.Exit(0)
|
|
1648
|
+
try:
|
|
1649
|
+
entries = api.log(contract, revision_range)
|
|
1650
|
+
except ValueError as error:
|
|
1651
|
+
console.print(f"[red]{error}[/]")
|
|
1652
|
+
raise typer.Exit(2) from error
|
|
1653
|
+
if json_output:
|
|
1654
|
+
typer.echo(json.dumps(entries, indent=2))
|
|
1655
|
+
raise typer.Exit(0)
|
|
1656
|
+
if not entries:
|
|
1657
|
+
console.print("no commits touch this file")
|
|
1658
|
+
raise typer.Exit(0)
|
|
1659
|
+
for entry in entries:
|
|
1660
|
+
console.print(
|
|
1661
|
+
f"{entry['sha'][:7]} {entry['date']} {entry['author']} "
|
|
1662
|
+
f"{entry['verdict']} {entry['message']}",
|
|
1663
|
+
markup=False,
|
|
1664
|
+
highlight=False,
|
|
1665
|
+
)
|
|
1666
|
+
raise typer.Exit(0)
|
|
1667
|
+
|
|
1668
|
+
|
|
1669
|
+
@app.command("blame")
|
|
1670
|
+
def blame_cmd(
|
|
1671
|
+
contract: Path = typer.Argument(
|
|
1672
|
+
DEFAULT_CONTRACT, help="Contract file whose declared facts to attribute."
|
|
1673
|
+
),
|
|
1674
|
+
json_output: bool = typer.Option(False, "--json", help="Emit provenance rows as JSON."),
|
|
1675
|
+
) -> None:
|
|
1676
|
+
"""One provenance row per declared fact, with its witness and git commit."""
|
|
1677
|
+
try:
|
|
1678
|
+
entries, in_repository = api.blame(contract)
|
|
1679
|
+
except (UnsupportedSchemaVersion, OSError, TypeError, ValueError, YAMLError) as error:
|
|
1680
|
+
_refused("blame", error)
|
|
1681
|
+
raise typer.Exit(2) from error
|
|
1682
|
+
if json_output:
|
|
1683
|
+
typer.echo(json.dumps(entries, indent=2))
|
|
1684
|
+
raise typer.Exit(0)
|
|
1685
|
+
if not entries:
|
|
1686
|
+
console.print("no declared facts")
|
|
1687
|
+
raise typer.Exit(0)
|
|
1688
|
+
if not in_repository:
|
|
1689
|
+
console.print("not a git repository — rows have no commit attribution")
|
|
1690
|
+
for entry in entries:
|
|
1691
|
+
commit = entry.get("last_changing_commit", "unattributed")
|
|
1692
|
+
compared_from = (
|
|
1693
|
+
f" compared_from={entry['compared_from']}" if entry.get("compared_from") else ""
|
|
1694
|
+
)
|
|
1695
|
+
console.print(
|
|
1696
|
+
f"{entry['path']} {entry['witness']} {entry['source']}{compared_from} {commit}",
|
|
1697
|
+
markup=False,
|
|
1698
|
+
highlight=False,
|
|
1699
|
+
)
|
|
1700
|
+
raise typer.Exit(0)
|
|
1701
|
+
|
|
1702
|
+
|
|
1703
|
+
@app.command(
|
|
1704
|
+
"api-schema",
|
|
1705
|
+
# One short line on purpose: `_help_commands` in tests/test_parity.py reads
|
|
1706
|
+
# the first token of every line in `--help`'s Commands panel, so a
|
|
1707
|
+
# description that wraps invents a command that does not exist.
|
|
1708
|
+
help="Write the payload union's JSON schema, for the app's TypeScript types.",
|
|
1709
|
+
)
|
|
1710
|
+
def api_schema_cmd(
|
|
1711
|
+
out: Path = typer.Option(..., "--out", help="Where to write the JSON schema document."),
|
|
1712
|
+
) -> None:
|
|
1713
|
+
"""Write the payload union's JSON schema (E1-b) — every member document
|
|
1714
|
+
lockwire's doors emit, which is what the TypeScript types are generated from
|
|
1715
|
+
(packaging/generate-types.md). The library builds it, never this file
|
|
1716
|
+
: `api.api_schema` is the twin.
|
|
1717
|
+
"""
|
|
1718
|
+
api.api_schema(out)
|
|
1719
|
+
console.print(f"wrote {out}")
|
|
1720
|
+
|
|
1721
|
+
|
|
1722
|
+
@app.command(
|
|
1723
|
+
"api-types",
|
|
1724
|
+
# One short line on purpose: `_help_commands` in tests/test_parity.py reads
|
|
1725
|
+
# the first token of every line in `--help`'s Commands panel, so a
|
|
1726
|
+
# description that wraps invents a command that does not exist.
|
|
1727
|
+
help="Write app/src/shared/api.gen.ts directly from the payload union.",
|
|
1728
|
+
)
|
|
1729
|
+
def api_types_cmd(
|
|
1730
|
+
out: Path = typer.Option(..., "--out", help="Where to write the generated TypeScript."),
|
|
1731
|
+
) -> None:
|
|
1732
|
+
"""Write the payload union's TypeScript types (e1-d3) — no generator
|
|
1733
|
+
dependency, walked straight from `api_payload.py` (v8 line 265: the
|
|
1734
|
+
mainstream generator's own docs say a discriminated union like ours
|
|
1735
|
+
narrows badly through it). The library builds it, never this file
|
|
1736
|
+
: `api.api_types` is the twin.
|
|
1737
|
+
"""
|
|
1738
|
+
api.api_types(out)
|
|
1739
|
+
console.print(f"wrote {out}")
|
|
1740
|
+
|
|
1741
|
+
|
|
1742
|
+
@app.command("serve")
|
|
1743
|
+
def serve_cmd(
|
|
1744
|
+
host: str = typer.Option(
|
|
1745
|
+
"127.0.0.1", "--host", help="Bind address. Loopback only — anything else is refused."
|
|
1746
|
+
),
|
|
1747
|
+
port: int = typer.Option(
|
|
1748
|
+
0, "--port", help="Port to bind, or 0 to pick a free one (the default)."
|
|
1749
|
+
),
|
|
1750
|
+
robot: bool = typer.Option(
|
|
1751
|
+
False,
|
|
1752
|
+
"--robot",
|
|
1753
|
+
help="Robot mode: write commands are absent, not refused (plan v8 line 418).",
|
|
1754
|
+
),
|
|
1755
|
+
exit_on_stdin_close: bool = typer.Option(
|
|
1756
|
+
False,
|
|
1757
|
+
"--exit-on-stdin-close",
|
|
1758
|
+
help="Exit when stdin closes. The app passes this so no sidecar outlives it.",
|
|
1759
|
+
),
|
|
1760
|
+
profile: str | None = typer.Option(
|
|
1761
|
+
None, "--profile", hidden=True, help="Server policy a plugin enforces."
|
|
1762
|
+
),
|
|
1763
|
+
required_evidence: list[str] | None = typer.Option(
|
|
1764
|
+
None,
|
|
1765
|
+
"--require-evidence",
|
|
1766
|
+
hidden=True,
|
|
1767
|
+
help="Evidence a plugin's server policy requires; repeat for each class.",
|
|
1768
|
+
),
|
|
1769
|
+
) -> None:
|
|
1770
|
+
"""Start the sidecar: the same core, over local HTTP, for the Electron app.
|
|
1771
|
+
|
|
1772
|
+
Private to the app: loopback only, a fresh token every launch, no
|
|
1773
|
+
compatibility promise. A caller outside the app uses `lockwire.api`, this
|
|
1774
|
+
CLI's `--json`, or MCP.
|
|
1775
|
+
|
|
1776
|
+
Prints one JSON line on stdout when ready, then serves until SIGTERM — or,
|
|
1777
|
+
with --exit-on-stdin-close, until its stdin closes.
|
|
1778
|
+
"""
|
|
1779
|
+
try:
|
|
1780
|
+
import fastapi # noqa: F401
|
|
1781
|
+
import uvicorn # noqa: F401
|
|
1782
|
+
|
|
1783
|
+
from lockwire.serve import run_server
|
|
1784
|
+
except ImportError:
|
|
1785
|
+
print("install the serve extra: uv sync --extra serve", file=sys.stderr)
|
|
1786
|
+
raise typer.Exit(2) from None
|
|
1787
|
+
raise typer.Exit(
|
|
1788
|
+
run_server(
|
|
1789
|
+
host=host,
|
|
1790
|
+
port=port,
|
|
1791
|
+
robot=robot,
|
|
1792
|
+
exit_on_stdin_close=exit_on_stdin_close,
|
|
1793
|
+
profile=profile,
|
|
1794
|
+
required_evidence=required_evidence,
|
|
1795
|
+
)
|
|
1796
|
+
)
|
|
1797
|
+
|
|
1798
|
+
|
|
1799
|
+
plugins.load(app=app) # after every free command is defined, so paid ones append to the same app
|