luciazero 2.0.1 → 2.0.2

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.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,18 @@ Versioning: [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [2.0.2] - 2026-08-13
11
+
12
+ ### Fixed
13
+
14
+ - `/lucia-relay` now records whether its recipient is on the same machine or a
15
+ different one before producing pointers. Schema 2 permits full paths only
16
+ for same-machine delivery; cross-machine validation requires a clean HEAD
17
+ reachable from a locally known remote branch, rejects machine-only paths,
18
+ and provides `knowledge.inline` for otherwise-local context. Schema 1 relays
19
+ remain readable as same-machine-only legacy artifacts, and legacy `draft`
20
+ callers without `--recipient` safely default to same-machine delivery.
21
+
10
22
  ## [2.0.1] - 2026-08-13
11
23
 
12
24
  ### Added
package/README.md CHANGED
@@ -57,6 +57,11 @@ human view; session B checks the Git fingerprint, reads the exact next action
57
57
  and refuted hypotheses, re-runs verification, then explicitly consumes the
58
58
  relay.
59
59
 
60
+ Relay decides where the recipient is before it writes pointers. Same-machine
61
+ receivers may use full local paths. Cross-machine relays require a clean pushed
62
+ commit, reject machine-only paths, and carry otherwise-local knowledge inline
63
+ in the JSON.
64
+
60
65
  <p align="center">
61
66
  <img src="https://raw.githubusercontent.com/ohm41321/luciazero/main/docs/assets/relay-demo.gif" width="720" alt="One session creates a Lucia Relay; another validates it, detects repository drift, re-runs evidence, and consumes it">
62
67
  </p>
package/README.th.md CHANGED
@@ -56,6 +56,10 @@ Session A สร้าง `LUCIA_RELAY.json` ที่เป็น canonical พ
56
56
  ที่ generate จากไฟล์นั้น ส่วน Session B ตรวจ Git fingerprint, อ่าน next action
57
57
  กับสมมติฐานที่ถูกหักล้าง, รัน verification ซ้ำ แล้ว consume relay อย่างชัดเจน
58
58
 
59
+ Relay ต้องตัดสินก่อนว่าผู้รับอยู่ที่ไหน: ถ้าอยู่เครื่องเดิมใช้ full local path
60
+ ได้ แต่ถ้าข้ามเครื่องต้องเป็น commit ที่ clean และ push แล้วเท่านั้น ตัวตรวจจะ
61
+ ปฏิเสธ path เฉพาะเครื่อง และให้ใส่ความรู้ที่อยู่นอก repo ลงใน JSON โดยตรง
62
+
59
63
  <p align="center">
60
64
  <img src="https://raw.githubusercontent.com/ohm41321/luciazero/main/docs/assets/relay-demo.gif" width="720" alt="Session หนึ่งสร้าง Lucia Relay อีก session ตรวจสอบ พบ repository drift รันหลักฐานซ้ำ และ consume relay">
61
65
  </p>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "luciazero",
3
- "version": "2.0.1",
3
+ "version": "2.0.2",
4
4
  "description": "Verification-first discipline for coding agents (Claude Code + Codex CLI): 9-rule doctrine, 9 skills, risk-routed reviewer, fail-open enforcement hooks. npx luciazero installs it.",
5
5
  "repository": { "type": "git", "url": "git+https://github.com/ohm41321/luciazero.git" },
6
6
  "homepage": "https://github.com/ohm41321/luciazero#readme",
@@ -7,24 +7,34 @@ description: Package unfinished work and non-obvious knowledge into a portable,
7
7
 
8
8
  `/retro` stores permanent lessons. `/lucia-relay` transfers task-local state. The canonical artifact is `LUCIA_RELAY.json`; `LUCIA_RELAY.md` is its generated human view. A receiver trusts neither until the tree and verification evidence agree.
9
9
 
10
+ ## Decide the route first
11
+
12
+ Before writing or pointing anywhere, answer: **Where is the recipient?**
13
+
14
+ - `same-machine`: the receiver shares this machine. Full local paths are allowed.
15
+ - `cross-machine`: the receiver cannot open anything local. Every pointer must be repo-relative and present in a pushed commit, or the needed knowledge must be copied into `knowledge.inline` in `LUCIA_RELAY.json`.
16
+
17
+ If the destination is not explicit and cannot be inferred, ask the user. Never assume a local path will travel.
18
+
10
19
  ## Produce
11
20
 
12
- 1. Locate this skill's installed directory and run `<this-skill-dir>/scripts/relay.py draft --root .` to see the schema and captured repository fingerprint.
13
- 2. Create `LUCIA_RELAY.json` at the repo root. Fill the original goal, completed and in-progress work, one literal next action, verification evidence, relevant files, read-first pointers, open **and refuted** hypotheses, and landmines. Keep the captured `repository` object from the draft.
14
- 3. Run `<this-skill-dir>/scripts/relay.py render --root .`; fix every validation error and review both artifacts for secrets or machine-only paths.
21
+ 1. Locate this skill's installed directory and run `<this-skill-dir>/scripts/relay.py draft --root . --recipient <same-machine|cross-machine>`. Always pass the route explicitly; the CLI's `same-machine` default exists only so older callers keep working.
22
+ 2. Create `LUCIA_RELAY.json` at the repo root. Fill the original goal, completed and in-progress work, one literal next action, verification evidence, relevant files, read-first pointers, inline knowledge, open **and refuted** hypotheses, and landmines. Keep the captured `route` and `repository` objects from the draft.
23
+ 3. For `cross-machine`, commit and push every task file first. Start each `read_first` entry with its repo-relative tracked path; an optional note may follow ` — `. Replace machine-local document, memory, or artifact pointers with a short `{ "label": "...", "content": "..." }` entry in `knowledge.inline`. Do not include credentials.
24
+ 4. Run `<this-skill-dir>/scripts/relay.py render --root .`; fix every validation error and review both artifacts for secrets and route mistakes.
15
25
 
16
26
  Evidence rules:
17
27
 
18
28
  - A verification entry includes the exact command, exit code, shortest decisive line, and run time. Never turn “not run” into implied green.
19
29
  - The next action is executable: a command, exact edit, or decision. “Continue the refactor” is invalid.
20
30
  - Preserve refuted hypotheses and why they failed. They are the knowledge most likely to save the receiver time.
21
- - Point to relevant `docs/lessons.md` entries. Copy a machine-local heuristic only when it is essential and contains no secret, credential location, personal path, or preference.
31
+ - Point to relevant `docs/lessons.md` entries. For cross-machine delivery, inline an essential machine-local heuristic instead of pointing to its local file; omit secrets, credential locations, personal paths, and preferences.
22
32
 
23
33
  ## Route
24
34
 
25
35
  - Same worktree/session boundary: leave both files uncommitted; the SessionStart hook points at them.
26
- - Another local agent or harness sharing the tree: give it both paths and ask it to run `inspect` before editing.
27
- - Cross-machine/person: attach both files. If repository policy requires a branch transfer, review both files for secrets, then force-add the ignored transient artifacts with `git add -f LUCIA_RELAY.json LUCIA_RELAY.md`. State explicitly that they must be consumed and removed after re-verification.
36
+ - Another local agent or harness sharing the tree: give it both full paths and ask it to run `inspect` before editing.
37
+ - Cross-machine/person: give the pushed repository ref plus both artifacts. The validator rejects a dirty tree, a HEAD absent from locally known remote branches, and machine-only paths. If repository policy requires the relay files on the branch, review them for secrets, then force-add the ignored transient artifacts with `git add -f LUCIA_RELAY.json LUCIA_RELAY.md`. State explicitly that they must be consumed and removed after re-verification.
28
38
  - Chat-only channel: paste the JSON artifact; it is canonical and can regenerate the Markdown view.
29
39
 
30
40
  Do not copy the conversation transcript. Transfer decisions, evidence, negative knowledge, and pointers to source-of-truth files.
@@ -9,11 +9,11 @@ import hashlib
9
9
  import html
10
10
  import json
11
11
  import os
12
- from pathlib import Path
12
+ from pathlib import Path, PurePosixPath
13
13
  import re
14
14
  import subprocess
15
15
  import sys
16
- from typing import Any
16
+ from typing import Any, Optional
17
17
 
18
18
 
19
19
  MANIFEST = "LUCIA_RELAY.json"
@@ -24,7 +24,13 @@ SECRET_PATTERNS = (
24
24
  re.compile(r"\bgh[pousr]_[A-Za-z0-9]{20,}\b"),
25
25
  re.compile(r"\bAKIA[0-9A-Z]{16}\b"),
26
26
  )
27
- MACHINE_PATH = re.compile(r"(?:/Users/[^/\s]+|/home/[^/\s]+|[A-Za-z]:\\Users\\[^\\\s]+)")
27
+ POSIX_ABSOLUTE_PATH = re.compile(
28
+ r"(?<![A-Za-z0-9+.\-:/~$%])/(?!/)[^\s\"'`<>]+"
29
+ )
30
+ WINDOWS_ABSOLUTE_PATH = re.compile(r"(?<![A-Za-z0-9])[A-Za-z]:[\\/][^\s\"'`<>]+")
31
+ UNC_PATH = re.compile(r"(?<!\\)\\\\[^\\\s\"'`<>]+\\[^\s\"'`<>]+")
32
+ HOME_PATH = re.compile(r"(?<![A-Za-z0-9])(?:~|\$HOME|%USERPROFILE%)[\\/][^\s\"'`<>]+")
33
+ FILE_URI_PATH = re.compile(r"\bfile:[^\s\"'`<>]+", re.IGNORECASE)
28
34
 
29
35
 
30
36
  def git(root: Path, *args: str) -> tuple[int, str]:
@@ -42,7 +48,13 @@ def git(root: Path, *args: str) -> tuple[int, str]:
42
48
  def repository_snapshot(root: Path) -> dict[str, Any]:
43
49
  rc, top = git(root, "rev-parse", "--show-toplevel")
44
50
  if rc != 0:
45
- return {"head": None, "branch": None, "dirty": None, "diff_sha256": None}
51
+ return {
52
+ "head": None,
53
+ "branch": None,
54
+ "dirty": None,
55
+ "diff_sha256": None,
56
+ "known_remote_refs": [],
57
+ }
46
58
  repo = Path(top)
47
59
  head_rc, head = git(repo, "rev-parse", "HEAD")
48
60
  _, branch = git(repo, "branch", "--show-current")
@@ -82,22 +94,38 @@ def repository_snapshot(root: Path) -> dict[str, Any]:
82
94
  except OSError as exc:
83
95
  digest.update(f"unreadable:{exc.errno}".encode())
84
96
  fingerprint = digest.hexdigest()
97
+ known_remote_refs: list[str] = []
98
+ if head_rc == 0:
99
+ _, remote_refs = git(
100
+ repo,
101
+ "for-each-ref",
102
+ "--contains=HEAD",
103
+ "--format=%(refname)",
104
+ "refs/remotes",
105
+ )
106
+ known_remote_refs = sorted(
107
+ ref[len("refs/remotes/") :]
108
+ for ref in remote_refs.splitlines()
109
+ if ref.startswith("refs/remotes/") and not ref.endswith("/HEAD")
110
+ )
85
111
  return {
86
112
  "head": head if head_rc == 0 else None,
87
113
  "branch": branch or "(detached)",
88
114
  "dirty": bool(modified or untracked),
89
115
  "diff_sha256": fingerprint,
116
+ "known_remote_refs": known_remote_refs,
90
117
  "files": {"modified": modified, "untracked": untracked},
91
118
  }
92
119
 
93
120
 
94
- def draft(root: Path) -> dict[str, Any]:
121
+ def draft(root: Path, recipient: str) -> dict[str, Any]:
95
122
  snap = repository_snapshot(root)
96
123
  files = snap.pop("files", {"modified": [], "untracked": []})
97
124
  return {
98
- "schema": 1,
125
+ "schema": 2,
99
126
  "kind": "luciazero-relay",
100
127
  "created_at": dt.datetime.now(dt.timezone.utc).isoformat(timespec="seconds"),
128
+ "route": {"recipient": recipient},
101
129
  "source": {
102
130
  "harness": os.environ.get("LUCIAZERO_HARNESS", "unknown"),
103
131
  "agent": None,
@@ -111,7 +139,7 @@ def draft(root: Path) -> dict[str, Any]:
111
139
  "next_step": {"kind": "command", "value": ""},
112
140
  },
113
141
  "verification": [],
114
- "knowledge": {"read_first": [], "hypotheses": [], "landmines": []},
142
+ "knowledge": {"read_first": [], "inline": [], "hypotheses": [], "landmines": []},
115
143
  "files": files,
116
144
  }
117
145
 
@@ -135,13 +163,105 @@ def nonempty(value: Any) -> bool:
135
163
  return isinstance(value, str) and bool(value.strip())
136
164
 
137
165
 
166
+ def string_values(value: Any):
167
+ if isinstance(value, str):
168
+ yield value
169
+ elif isinstance(value, dict):
170
+ for nested in value.values():
171
+ yield from string_values(nested)
172
+ elif isinstance(value, list):
173
+ for nested in value:
174
+ yield from string_values(nested)
175
+
176
+
177
+ def machine_paths(data: dict[str, Any]) -> list[str]:
178
+ found: set[str] = set()
179
+ for value in string_values(data):
180
+ for pattern in (
181
+ POSIX_ABSOLUTE_PATH,
182
+ WINDOWS_ABSOLUTE_PATH,
183
+ UNC_PATH,
184
+ HOME_PATH,
185
+ FILE_URI_PATH,
186
+ ):
187
+ found.update(match.group(0) for match in pattern.finditer(value))
188
+ return sorted(found)
189
+
190
+
191
+ def repo_pointer(value: Any) -> Optional[str]:
192
+ if not nonempty(value):
193
+ return None
194
+ # A short annotation may follow the path, as in
195
+ # "docs/lessons.md — quoted delimiters are parser syntax".
196
+ candidate = str(value).split(" — ", 1)[0].strip()
197
+ path = PurePosixPath(candidate)
198
+ if (
199
+ not candidate
200
+ or "\\" in candidate
201
+ or ":" in candidate
202
+ or path.is_absolute()
203
+ or any(part in ("", ".", "..") for part in path.parts)
204
+ ):
205
+ return None
206
+ return candidate
207
+
208
+
209
+ def cross_machine_repository_errors(root: Path, data: dict[str, Any]) -> list[str]:
210
+ route = data.get("route") if isinstance(data.get("route"), dict) else {}
211
+ if route.get("recipient") != "cross-machine":
212
+ return []
213
+ repository = data.get("repository") if isinstance(data.get("repository"), dict) else {}
214
+ head = repository.get("head")
215
+ knowledge = data.get("knowledge") if isinstance(data.get("knowledge"), dict) else {}
216
+ read_first = knowledge.get("read_first")
217
+ if not isinstance(read_first, list):
218
+ return []
219
+ errors: list[str] = []
220
+ for index, item in enumerate(read_first):
221
+ path = repo_pointer(item)
222
+ if path is None:
223
+ errors.append(
224
+ f"knowledge.read_first[{index}] must start with one repo-relative path; move prose or local knowledge to knowledge.inline"
225
+ )
226
+ continue
227
+ if not nonempty(head):
228
+ errors.append(f"knowledge.read_first[{index}] cannot be checked without repository.head")
229
+ continue
230
+ rc, _ = git(root, "cat-file", "-e", f"{head}:{path}")
231
+ if rc != 0:
232
+ errors.append(
233
+ f"knowledge.read_first[{index}] path is not present in the pushed relay commit: {path}"
234
+ )
235
+ continue
236
+ _, entry = git(root, "ls-tree", str(head), "--", path)
237
+ if entry.startswith("120000 "):
238
+ errors.append(
239
+ f"knowledge.read_first[{index}] must not use a symlink for cross-machine delivery: {path}"
240
+ )
241
+ return errors
242
+
243
+
138
244
  def validate(data: dict[str, Any]) -> tuple[list[str], list[str]]:
139
245
  errors: list[str] = []
140
246
  warnings: list[str] = []
141
- if data.get("schema") != 1:
142
- errors.append("schema must equal 1")
247
+ schema = data.get("schema")
248
+ if schema not in (1, 2):
249
+ errors.append("schema must equal 1 or 2")
143
250
  if data.get("kind") != "luciazero-relay":
144
251
  errors.append("kind must equal luciazero-relay")
252
+ recipient = None
253
+ route = data.get("route")
254
+ if schema == 2:
255
+ if not isinstance(route, dict):
256
+ errors.append("route must be an object")
257
+ else:
258
+ recipient = route.get("recipient")
259
+ if recipient not in ("same-machine", "cross-machine"):
260
+ errors.append("route.recipient must be same-machine or cross-machine")
261
+ elif schema == 1:
262
+ warnings.append(
263
+ "legacy schema 1 has no recipient location; treat it as same-machine and create a new relay before routing cross-machine"
264
+ )
145
265
  if not nonempty(data.get("created_at")):
146
266
  errors.append("created_at is required")
147
267
  else:
@@ -202,6 +322,17 @@ def validate(data: dict[str, Any]) -> tuple[list[str], list[str]]:
202
322
  for key in ("read_first", "hypotheses", "landmines"):
203
323
  if not isinstance(knowledge.get(key), list):
204
324
  errors.append(f"knowledge.{key} must be an array")
325
+ inline = knowledge.get("inline")
326
+ if schema == 2 and not isinstance(inline, list):
327
+ errors.append("knowledge.inline must be an array")
328
+ elif isinstance(inline, list):
329
+ for index, item in enumerate(inline):
330
+ prefix = f"knowledge.inline[{index}]"
331
+ if not isinstance(item, dict):
332
+ errors.append(f"{prefix} must be an object")
333
+ continue
334
+ if not nonempty(item.get("label")) or not nonempty(item.get("content")):
335
+ errors.append(f"{prefix} requires label and content")
205
336
  hypotheses = knowledge.get("hypotheses")
206
337
  if isinstance(hypotheses, list):
207
338
  for index, item in enumerate(hypotheses):
@@ -224,6 +355,12 @@ def validate(data: dict[str, Any]) -> tuple[list[str], list[str]]:
224
355
  for key in ("head", "branch", "dirty", "diff_sha256"):
225
356
  if key not in repository:
226
357
  errors.append(f"repository.{key} is required")
358
+ if schema == 2:
359
+ known_remote_refs = repository.get("known_remote_refs")
360
+ if not isinstance(known_remote_refs, list):
361
+ errors.append("repository.known_remote_refs must be an array")
362
+ elif not all(nonempty(ref) for ref in known_remote_refs):
363
+ errors.append("repository.known_remote_refs entries must be non-empty strings")
227
364
  files = data.get("files")
228
365
  if not isinstance(files, dict):
229
366
  errors.append("files must be an object")
@@ -235,8 +372,26 @@ def validate(data: dict[str, Any]) -> tuple[list[str], list[str]]:
235
372
  serialized = json.dumps(data, ensure_ascii=False)
236
373
  if any(pattern.search(serialized) for pattern in SECRET_PATTERNS):
237
374
  errors.append("possible secret or private key detected; remove it before routing")
238
- if MACHINE_PATH.search(serialized):
239
- warnings.append("machine-specific absolute path detected; prefer a repo-relative path")
375
+ local_paths = machine_paths(data)
376
+ if recipient == "cross-machine":
377
+ if local_paths:
378
+ errors.append(
379
+ "cross-machine relay contains machine-only paths: "
380
+ + ", ".join(local_paths[:3])
381
+ + (" ..." if len(local_paths) > 3 else "")
382
+ + "; use pushed repo-relative paths or copy the knowledge into knowledge.inline"
383
+ )
384
+ if isinstance(repository, dict):
385
+ if repository.get("dirty") is True:
386
+ errors.append(
387
+ "cross-machine relay cannot depend on a dirty worktree; commit and push task files, then keep machine-local knowledge in knowledge.inline"
388
+ )
389
+ if not repository.get("known_remote_refs"):
390
+ errors.append(
391
+ "cross-machine relay HEAD is not reachable from a locally known remote branch; push it before routing"
392
+ )
393
+ elif schema == 1 and local_paths:
394
+ warnings.append("legacy relay contains a machine-specific path and is not portable cross-machine")
240
395
  return errors, warnings
241
396
 
242
397
 
@@ -262,6 +417,20 @@ def bullets(values: Any, empty: str = "None recorded.") -> list[str]:
262
417
  return lines
263
418
 
264
419
 
420
+ def inline_knowledge(values: Any) -> list[str]:
421
+ if not isinstance(values, list) or not values:
422
+ return ["None recorded."]
423
+ lines = []
424
+ for item in values:
425
+ if not isinstance(item, dict):
426
+ lines.append(f"- {code_inline(json.dumps(item, ensure_ascii=False, sort_keys=True))}")
427
+ continue
428
+ label = markdown_text(item.get("label", "unlabelled"))
429
+ content = markdown_text(item.get("content", "")).replace("\n", "<br>\n")
430
+ lines.append(f"- {label}: {content}")
431
+ return lines
432
+
433
+
265
434
  def table_cell(value: Any) -> str:
266
435
  text = str(value if value is not None else "not recorded").replace("\n", " ")
267
436
  return markdown_text(text)
@@ -274,6 +443,10 @@ def render_markdown(data: dict[str, Any]) -> str:
274
443
  knowledge = data.get("knowledge") if isinstance(data.get("knowledge"), dict) else {}
275
444
  files = data.get("files") if isinstance(data.get("files"), dict) else {}
276
445
  next_step = state.get("next_step") if isinstance(state.get("next_step"), dict) else {}
446
+ route = data.get("route") if isinstance(data.get("route"), dict) else {}
447
+ known_remote_refs = repo.get("known_remote_refs")
448
+ if not isinstance(known_remote_refs, list):
449
+ known_remote_refs = []
277
450
  out = [
278
451
  "# Lucia Relay",
279
452
  "",
@@ -281,6 +454,10 @@ def render_markdown(data: dict[str, Any]) -> str:
281
454
  "",
282
455
  f"Created: {markdown_text(data.get('created_at', 'not recorded'))}",
283
456
  f"Source: {markdown_text(source.get('harness', 'unknown'))} / {markdown_text(source.get('agent') or 'agent not named')}",
457
+ ]
458
+ if data.get("schema") == 2:
459
+ out.append(f"Recipient: {markdown_text(route.get('recipient', 'not recorded'))}")
460
+ out.extend([
284
461
  "",
285
462
  "## Goal",
286
463
  "",
@@ -292,6 +469,10 @@ def render_markdown(data: dict[str, Any]) -> str:
292
469
  f"- Branch: {code_inline(repo.get('branch') or 'not recorded')}",
293
470
  f"- Dirty: {code_inline(repo.get('dirty'))}",
294
471
  f"- Diff SHA-256: {code_inline(repo.get('diff_sha256') or 'not recorded')}",
472
+ ])
473
+ if data.get("schema") == 2:
474
+ out.append(f"- Known remote refs: {code_inline(', '.join(str(ref) for ref in known_remote_refs) or 'none')}")
475
+ out.extend([
295
476
  "",
296
477
  "## Done",
297
478
  "",
@@ -309,7 +490,7 @@ def render_markdown(data: dict[str, Any]) -> str:
309
490
  "",
310
491
  "| Command | Exit | Decisive line | Run at |",
311
492
  "|---|---:|---|---|",
312
- ]
493
+ ])
313
494
  verification = data.get("verification")
314
495
  if isinstance(verification, list) and verification:
315
496
  for item in verification:
@@ -324,6 +505,8 @@ def render_markdown(data: dict[str, Any]) -> str:
324
505
  else:
325
506
  out.append("| Not run | — | No verification evidence recorded | — |")
326
507
  out.extend(["", "## Read first", "", *bullets(knowledge.get("read_first"))])
508
+ if data.get("schema") == 2:
509
+ out.extend(["", "## Inline knowledge", "", *inline_knowledge(knowledge.get("inline"))])
327
510
  out.extend(["", "## Hypotheses", "", *bullets(knowledge.get("hypotheses"))])
328
511
  out.extend(["", "## Landmines", "", *bullets(knowledge.get("landmines"))])
329
512
  out.extend(["", "## Files", "", "Modified:", "", *bullets(files.get("modified"))])
@@ -348,9 +531,20 @@ def inspect(root: Path, data: dict[str, Any], check_human: bool = False) -> dict
348
531
  if actual != render_markdown(data):
349
532
  errors.append(f"{HUMAN} does not match {MANIFEST}; regenerate it before trusting or consuming the relay")
350
533
  current = repository_snapshot(root)
534
+ errors.extend(cross_machine_repository_errors(root, data))
351
535
  recorded = data.get("repository") if isinstance(data.get("repository"), dict) else {}
536
+ route = data.get("route") if isinstance(data.get("route"), dict) else {}
537
+ if route.get("recipient") == "cross-machine":
538
+ if current.get("dirty") is True:
539
+ errors.append("current cross-machine worktree is dirty; create a fresh relay after committing")
540
+ if not current.get("known_remote_refs"):
541
+ errors.append("current cross-machine HEAD is not reachable from a locally known remote branch")
352
542
  compared = ("head", "branch", "dirty", "diff_sha256")
353
543
  drift_fields = [key for key in compared if recorded.get(key) != current.get(key)]
544
+ if route.get("recipient") == "cross-machine" and drift_fields:
545
+ errors.append(
546
+ "cross-machine relay repository fingerprint is stale; create a fresh relay from the clean pushed tree"
547
+ )
354
548
  age_days = None
355
549
  try:
356
550
  created = dt.datetime.fromisoformat(str(data.get("created_at", "")).replace("Z", "+00:00"))
@@ -368,13 +562,26 @@ def inspect(root: Path, data: dict[str, Any], check_human: bool = False) -> dict
368
562
  "recorded_repository": recorded,
369
563
  "current_repository": current,
370
564
  "next_step": state.get("next_step"),
565
+ "recipient": (
566
+ data.get("route", {}).get("recipient")
567
+ if isinstance(data.get("route"), dict)
568
+ else "legacy / same-machine only"
569
+ ),
371
570
  }
372
571
 
373
572
 
374
573
  def parser() -> argparse.ArgumentParser:
375
574
  top = argparse.ArgumentParser(description=__doc__)
376
575
  sub = top.add_subparsers(dest="command", required=True)
377
- for name in ("draft", "render", "validate", "inspect"):
576
+ draft_command = sub.add_parser("draft")
577
+ draft_command.add_argument("--root", default=".")
578
+ draft_command.add_argument(
579
+ "--recipient",
580
+ default="same-machine",
581
+ choices=("same-machine", "cross-machine"),
582
+ help="where the receiver will consume this relay (default: same-machine for legacy callers)",
583
+ )
584
+ for name in ("render", "validate", "inspect"):
378
585
  command = sub.add_parser(name)
379
586
  command.add_argument("--root", default=".")
380
587
  if name == "inspect":
@@ -389,7 +596,7 @@ def main() -> int:
389
596
  args = parser().parse_args()
390
597
  root = Path(args.root).resolve()
391
598
  if args.command == "draft":
392
- print(json.dumps(draft(root), ensure_ascii=False, indent=2))
599
+ print(json.dumps(draft(root, args.recipient), ensure_ascii=False, indent=2))
393
600
  return 0
394
601
  try:
395
602
  data = load_manifest(root)
@@ -403,7 +610,7 @@ def main() -> int:
403
610
  for message in result["warnings"]:
404
611
  print(f"WARN {message}", file=sys.stderr)
405
612
  if result["valid"]:
406
- print("VALID luciazero-relay schema=1")
613
+ print(f"VALID luciazero-relay schema={data.get('schema')}")
407
614
  return 0 if result["valid"] else 1
408
615
  if args.command == "render":
409
616
  if not result["valid"]:
@@ -424,6 +631,7 @@ def main() -> int:
424
631
  print(json.dumps(result, ensure_ascii=False, indent=2))
425
632
  else:
426
633
  print(f"Relay: {'valid' if result['valid'] else 'invalid'}, age={result['age_days']}d")
634
+ print(f"Recipient: {result['recipient']}")
427
635
  print(f"Repository drift: {'yes' if result['repository_drift'] else 'no'}")
428
636
  if result["drift_fields"]:
429
637
  print("Changed fingerprint fields: " + ", ".join(result["drift_fields"]))