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 +12 -0
- package/README.md +5 -0
- package/README.th.md +4 -0
- package/package.json +1 -1
- package/skills/lucia-relay/SKILL.md +16 -6
- package/skills/lucia-relay/scripts/relay.py +223 -15
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.
|
|
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
|
|
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`
|
|
14
|
-
3.
|
|
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.
|
|
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:
|
|
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
|
-
|
|
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 {
|
|
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":
|
|
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
|
-
|
|
142
|
-
|
|
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
|
-
|
|
239
|
-
|
|
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
|
-
|
|
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=
|
|
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"]))
|