luciazero 2.0.0 → 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 +33 -0
- package/README.md +34 -0
- package/README.th.md +32 -0
- package/bin/luciazero.js +9 -2
- package/bin/update.js +366 -0
- package/install.sh +2 -1
- package/package.json +1 -1
- package/skills/lucia-relay/SKILL.md +16 -6
- package/skills/lucia-relay/scripts/relay.py +223 -15
- package/uninstall.sh +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,39 @@ 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
|
+
|
|
22
|
+
## [2.0.1] - 2026-08-13
|
|
23
|
+
|
|
24
|
+
### Added
|
|
25
|
+
|
|
26
|
+
- **Explicit, channel-aware updates.** `npx luciazero@latest check-update`
|
|
27
|
+
performs a read-only, five-second npm registry check only when invoked;
|
|
28
|
+
`npx luciazero@latest update` detects classic Claude, its hook mode, and
|
|
29
|
+
Codex, then refreshes every detected install through the existing audited
|
|
30
|
+
installers. It refuses to create a fresh install, downgrade a recognized
|
|
31
|
+
newer one, or trust a malformed version sidecar. Legacy installs without the
|
|
32
|
+
sidecar remain updatable. Classic doctrine customization now receives the
|
|
33
|
+
same managed-snapshot backup protection as skills and agents.
|
|
34
|
+
Plugin and skills-only update commands, Claude plugin auto-update, and GitHub
|
|
35
|
+
release notifications are documented separately in both READMEs.
|
|
36
|
+
|
|
37
|
+
### Changed
|
|
38
|
+
|
|
39
|
+
- The release workflow uses the runner's GitHub CLI instead of a Node 20-based
|
|
40
|
+
release action. Re-runs replace the existing zip without recreating the
|
|
41
|
+
release, and GitHub Actions no longer emits the deprecated-runtime warning.
|
|
42
|
+
|
|
10
43
|
## [2.0.0] - 2026-08-13
|
|
11
44
|
|
|
12
45
|
### Changed
|
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>
|
|
@@ -106,6 +111,35 @@ Classic installs support `--status`; Codex receives the doctrine and skills but
|
|
|
106
111
|
not Claude-only hooks/statusline. Installers back up name collisions and remove
|
|
107
112
|
only exact Luciazero-managed copies on uninstall.
|
|
108
113
|
|
|
114
|
+
## Update
|
|
115
|
+
|
|
116
|
+
Luciazero never changes classic or Codex files in the background.
|
|
117
|
+
|
|
118
|
+
```bash
|
|
119
|
+
npx luciazero@latest check-update # read-only; contacts npm only now
|
|
120
|
+
npx luciazero@latest update # updates every detected classic/Codex install
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
`update` preserves whether the Claude classic install uses hooks, repairs stale
|
|
124
|
+
managed files, starts no fresh install when it cannot find one, and stops on a
|
|
125
|
+
known newer version or malformed version metadata. Start a new agent session
|
|
126
|
+
afterward.
|
|
127
|
+
|
|
128
|
+
Other install channels use their own updater:
|
|
129
|
+
|
|
130
|
+
```bash
|
|
131
|
+
claude plugin update luciazero@luciazero # then run /reload-plugins
|
|
132
|
+
npx skills update # review the scope prompt
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
The skills command updates every installed skill in the selected scope, not
|
|
136
|
+
only Luciazero; review its prompt before confirming.
|
|
137
|
+
|
|
138
|
+
Claude Code can auto-update the plugin at startup: open `/plugin` →
|
|
139
|
+
**Marketplaces** → **luciazero** → **Enable auto-update**. Third-party
|
|
140
|
+
marketplaces leave this off by default. For release-only notifications, use
|
|
141
|
+
GitHub **Watch → Custom → Releases**.
|
|
142
|
+
|
|
109
143
|
## The 9 skills
|
|
110
144
|
|
|
111
145
|
Run `/luciazero-bootstrap` first; the rest activate when their moment arrives.
|
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>
|
|
@@ -104,6 +108,34 @@ npx luciazero uninstall-codex
|
|
|
104
108
|
เฉพาะ Claude Installer สำรองชื่อที่ชน และตอนถอนจะลบเฉพาะสำเนาที่ Luciazero
|
|
105
109
|
ยืนยันความเป็นเจ้าของได้
|
|
106
110
|
|
|
111
|
+
## อัปเดต
|
|
112
|
+
|
|
113
|
+
Luciazero จะไม่แก้ไฟล์ของ classic หรือ Codex อยู่เบื้องหลัง
|
|
114
|
+
|
|
115
|
+
```bash
|
|
116
|
+
npx luciazero@latest check-update # อ่านอย่างเดียว ติดต่อ npm เฉพาะตอนนี้
|
|
117
|
+
npx luciazero@latest update # อัปเดต classic/Codex ทุกชุดที่ตรวจพบ
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
`update` รักษาโหมดเดิมว่า Claude classic ใช้ hook หรือไม่ ซ่อมไฟล์ managed ที่
|
|
121
|
+
เก่า จะไม่เริ่มติดตั้งใหม่ถ้าหา installation เดิมไม่พบ และจะหยุดเมื่อพบเวอร์ชัน
|
|
122
|
+
ที่ใหม่กว่าหรือข้อมูลเวอร์ชันเสีย หลังอัปเดตให้เริ่ม agent session ใหม่
|
|
123
|
+
|
|
124
|
+
ช่องทางอื่นใช้ตัวอัปเดตของช่องทางนั้น:
|
|
125
|
+
|
|
126
|
+
```bash
|
|
127
|
+
claude plugin update luciazero@luciazero # แล้วรัน /reload-plugins
|
|
128
|
+
npx skills update # ตรวจ scope ใน prompt ก่อนยืนยัน
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
คำสั่ง skills จะอัปเดต skill ทุกตัวใน scope ที่เลือก ไม่ใช่เฉพาะ Luciazero
|
|
132
|
+
จึงควรตรวจ prompt ก่อนยืนยัน
|
|
133
|
+
|
|
134
|
+
Claude Code อัปเดต plugin ตอนเริ่มโปรแกรมอัตโนมัติได้: เปิด `/plugin` →
|
|
135
|
+
**Marketplaces** → **luciazero** → **Enable auto-update** โดย marketplace
|
|
136
|
+
ภายนอกจะปิดตัวเลือกนี้เป็นค่าเริ่มต้น ถ้าต้องการเพียงการแจ้งเตือน release ให้ใช้
|
|
137
|
+
GitHub **Watch → Custom → Releases**
|
|
138
|
+
|
|
107
139
|
## Skill ทั้ง 9 ตัว
|
|
108
140
|
|
|
109
141
|
รัน `/luciazero-bootstrap` ก่อนหนึ่งครั้ง ที่เหลือใช้เมื่อถึงจังหวะของมัน
|
package/bin/luciazero.js
CHANGED
|
@@ -7,6 +7,8 @@
|
|
|
7
7
|
// npx luciazero uninstall -> uninstall.sh
|
|
8
8
|
// npx luciazero uninstall-codex -> uninstall-codex.sh
|
|
9
9
|
// npx luciazero discipline [options] -> local stats report
|
|
10
|
+
// npx luciazero check-update [--json] -> explicit npm version check
|
|
11
|
+
// npx luciazero update -> update detected classic installs
|
|
10
12
|
const { spawnSync } = require("node:child_process");
|
|
11
13
|
const path = require("node:path");
|
|
12
14
|
|
|
@@ -16,13 +18,18 @@ const ROUTES = {
|
|
|
16
18
|
uninstall: { runtime: "bash", script: "uninstall.sh" },
|
|
17
19
|
"uninstall-codex": { runtime: "bash", script: "uninstall-codex.sh" },
|
|
18
20
|
discipline: { runtime: process.execPath, script: "bin/discipline-report.js" },
|
|
21
|
+
"check-update": { runtime: process.execPath, script: "bin/update.js", args: ["check"] },
|
|
22
|
+
update: { runtime: process.execPath, script: "bin/update.js", args: ["update"] },
|
|
19
23
|
};
|
|
20
24
|
|
|
21
25
|
const args = process.argv.slice(2);
|
|
22
26
|
let route = "install";
|
|
23
27
|
if (args[0] && !args[0].startsWith("-")) {
|
|
24
28
|
if (!Object.prototype.hasOwnProperty.call(ROUTES, args[0])) {
|
|
25
|
-
console.error(
|
|
29
|
+
console.error(
|
|
30
|
+
`luciazero: unknown command '${args[0]}' ` +
|
|
31
|
+
"(install, codex, discipline, check-update, update, uninstall, uninstall-codex)"
|
|
32
|
+
);
|
|
26
33
|
process.exit(64);
|
|
27
34
|
}
|
|
28
35
|
route = args.shift();
|
|
@@ -37,7 +44,7 @@ if (process.platform === "win32" && selected.runtime === "bash") {
|
|
|
37
44
|
process.exit(1);
|
|
38
45
|
}
|
|
39
46
|
|
|
40
|
-
const result = spawnSync(selected.runtime, [script, ...args], { stdio: "inherit" });
|
|
47
|
+
const result = spawnSync(selected.runtime, [script, ...(selected.args || []), ...args], { stdio: "inherit" });
|
|
41
48
|
if (result.error) {
|
|
42
49
|
console.error("luciazero: could not run bash: " + result.error.message);
|
|
43
50
|
process.exit(1);
|
package/bin/update.js
ADDED
|
@@ -0,0 +1,366 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const fs = require("node:fs");
|
|
4
|
+
const os = require("node:os");
|
|
5
|
+
const path = require("node:path");
|
|
6
|
+
const { spawnSync } = require("node:child_process");
|
|
7
|
+
|
|
8
|
+
const ROOT = path.resolve(__dirname, "..");
|
|
9
|
+
const PACKAGE_NAME = "luciazero";
|
|
10
|
+
const PACKAGE_VERSION = require(path.join(ROOT, "package.json")).version;
|
|
11
|
+
|
|
12
|
+
function readText(file) {
|
|
13
|
+
try {
|
|
14
|
+
return fs.readFileSync(file, "utf8");
|
|
15
|
+
} catch (error) {
|
|
16
|
+
if (error && error.code === "ENOENT") return "";
|
|
17
|
+
throw error;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function readVersion(directory) {
|
|
22
|
+
const value = readText(path.join(directory, ".luciazero-version")).split(/\r?\n/, 1)[0].trim();
|
|
23
|
+
return value || null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function versionMetadata(directory) {
|
|
27
|
+
const file = path.join(directory, ".luciazero-version");
|
|
28
|
+
return { installedVersion: readVersion(directory), versionFilePresent: fs.existsSync(file) };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function detectInstallations(options = {}) {
|
|
32
|
+
const env = options.env || process.env;
|
|
33
|
+
const home = options.home || os.homedir();
|
|
34
|
+
const claudeDir = options.claudeDir || env.CLAUDE_CONFIG_DIR || path.join(home, ".claude");
|
|
35
|
+
const codexDir = options.codexDir || env.CODEX_HOME || path.join(home, ".codex");
|
|
36
|
+
const installations = [];
|
|
37
|
+
|
|
38
|
+
const claudeSettings = readText(path.join(claudeDir, "settings.json"));
|
|
39
|
+
const claudeInstalled =
|
|
40
|
+
fs.existsSync(path.join(claudeDir, ".luciazero-version")) ||
|
|
41
|
+
fs.existsSync(path.join(claudeDir, "luciazero.md")) ||
|
|
42
|
+
fs.existsSync(path.join(claudeDir, ".luciazero-managed"));
|
|
43
|
+
if (claudeInstalled) {
|
|
44
|
+
installations.push({
|
|
45
|
+
channel: "claude-classic",
|
|
46
|
+
configDir: claudeDir,
|
|
47
|
+
...versionMetadata(claudeDir),
|
|
48
|
+
hooks:
|
|
49
|
+
fs.existsSync(path.join(claudeDir, "hooks", "luciazero-verify.sh")) ||
|
|
50
|
+
claudeSettings.includes("hooks/luciazero-verify.sh"),
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const codexAgents = readText(path.join(codexDir, "AGENTS.md"));
|
|
55
|
+
const codexInstalled =
|
|
56
|
+
fs.existsSync(path.join(codexDir, ".luciazero-version")) ||
|
|
57
|
+
fs.existsSync(path.join(codexDir, ".luciazero-managed")) ||
|
|
58
|
+
codexAgents.includes("<!-- luciazero:start -->");
|
|
59
|
+
if (codexInstalled) {
|
|
60
|
+
installations.push({
|
|
61
|
+
channel: "codex",
|
|
62
|
+
configDir: codexDir,
|
|
63
|
+
...versionMetadata(codexDir),
|
|
64
|
+
hooks: false,
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return installations;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function parseSemver(value) {
|
|
72
|
+
const match = String(value || "").match(
|
|
73
|
+
/^v?(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/
|
|
74
|
+
);
|
|
75
|
+
if (!match) return null;
|
|
76
|
+
return {
|
|
77
|
+
core: match.slice(1, 4).map(Number),
|
|
78
|
+
prerelease: match[4] ? match[4].split(".") : [],
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function comparePrerelease(left, right) {
|
|
83
|
+
if (left.length === 0 && right.length === 0) return 0;
|
|
84
|
+
if (left.length === 0) return 1;
|
|
85
|
+
if (right.length === 0) return -1;
|
|
86
|
+
const length = Math.max(left.length, right.length);
|
|
87
|
+
for (let index = 0; index < length; index += 1) {
|
|
88
|
+
if (left[index] === undefined) return -1;
|
|
89
|
+
if (right[index] === undefined) return 1;
|
|
90
|
+
if (left[index] === right[index]) continue;
|
|
91
|
+
const leftNumber = /^\d+$/.test(left[index]);
|
|
92
|
+
const rightNumber = /^\d+$/.test(right[index]);
|
|
93
|
+
if (leftNumber && rightNumber) return Number(left[index]) < Number(right[index]) ? -1 : 1;
|
|
94
|
+
if (leftNumber !== rightNumber) return leftNumber ? -1 : 1;
|
|
95
|
+
return left[index] < right[index] ? -1 : 1;
|
|
96
|
+
}
|
|
97
|
+
return 0;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function compareSemver(leftValue, rightValue) {
|
|
101
|
+
const left = parseSemver(leftValue);
|
|
102
|
+
const right = parseSemver(rightValue);
|
|
103
|
+
if (!left || !right) return null;
|
|
104
|
+
for (let index = 0; index < left.core.length; index += 1) {
|
|
105
|
+
if (left.core[index] !== right.core[index]) {
|
|
106
|
+
return left.core[index] < right.core[index] ? -1 : 1;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return comparePrerelease(left.prerelease, right.prerelease);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function installationStatus(installation, latestVersion) {
|
|
113
|
+
const comparison = compareSemver(installation.installedVersion, latestVersion);
|
|
114
|
+
let status = "unknown";
|
|
115
|
+
if (comparison === 0) status = "current";
|
|
116
|
+
else if (comparison === -1) status = "update-available";
|
|
117
|
+
else if (comparison === 1) status = "ahead";
|
|
118
|
+
return { ...installation, status };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function buildCheckResult(installations, latestVersion) {
|
|
122
|
+
const checked = installations.map((installation) => installationStatus(installation, latestVersion));
|
|
123
|
+
return {
|
|
124
|
+
package: PACKAGE_NAME,
|
|
125
|
+
bundledVersion: PACKAGE_VERSION,
|
|
126
|
+
latestVersion,
|
|
127
|
+
cliUpdateAvailable: compareSemver(PACKAGE_VERSION, latestVersion) === -1,
|
|
128
|
+
updateAvailable:
|
|
129
|
+
compareSemver(PACKAGE_VERSION, latestVersion) === -1 ||
|
|
130
|
+
checked.some((installation) => installation.status === "update-available"),
|
|
131
|
+
installations: checked,
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async function fetchLatestVersion(options = {}) {
|
|
136
|
+
const registry = options.registry || process.env.npm_config_registry || "https://registry.npmjs.org/";
|
|
137
|
+
const request = options.fetch || globalThis.fetch;
|
|
138
|
+
if (typeof request !== "function") throw new Error("this Node.js runtime has no fetch support");
|
|
139
|
+
const base = registry.endsWith("/") ? registry : `${registry}/`;
|
|
140
|
+
const endpoint = new URL(`${PACKAGE_NAME}/latest`, base);
|
|
141
|
+
if (!["http:", "https:"].includes(endpoint.protocol)) {
|
|
142
|
+
throw new Error("npm registry must use http or https");
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const controller = new AbortController();
|
|
146
|
+
const timer = setTimeout(() => controller.abort(), options.timeoutMs || 5000);
|
|
147
|
+
try {
|
|
148
|
+
const response = await request(endpoint, {
|
|
149
|
+
headers: { Accept: "application/json", "User-Agent": `luciazero/${PACKAGE_VERSION}` },
|
|
150
|
+
redirect: "follow",
|
|
151
|
+
signal: controller.signal,
|
|
152
|
+
});
|
|
153
|
+
if (!response.ok) throw new Error(`registry returned HTTP ${response.status}`);
|
|
154
|
+
const payload = await response.json();
|
|
155
|
+
if (!payload || !parseSemver(payload.version)) throw new Error("registry returned an invalid version");
|
|
156
|
+
return payload.version;
|
|
157
|
+
} catch (error) {
|
|
158
|
+
if (error && error.name === "AbortError") throw new Error("registry request timed out");
|
|
159
|
+
throw error;
|
|
160
|
+
} finally {
|
|
161
|
+
clearTimeout(timer);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function channelLabel(installation) {
|
|
166
|
+
if (installation.channel === "codex") return "Codex";
|
|
167
|
+
return installation.hooks ? "Claude classic + hooks" : "Claude classic";
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function writeLine(stream, line = "") {
|
|
171
|
+
stream.write(`${line}\n`);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function printSeparateChannelHelp(stream) {
|
|
175
|
+
writeLine(stream, "Claude plugin: claude plugin update luciazero@luciazero, then /reload-plugins");
|
|
176
|
+
writeLine(stream, "Skills only: npx skills update (updates all skills in the selected scope; review the prompt)");
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function checkHelp(stream) {
|
|
180
|
+
writeLine(stream, "Usage: npx luciazero@latest check-update [--json]");
|
|
181
|
+
writeLine(stream, "Checks the npm registry only when this command is run; it never changes files.");
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
async function runCheck(args = [], dependencies = {}) {
|
|
185
|
+
const stdout = dependencies.stdout || process.stdout;
|
|
186
|
+
const stderr = dependencies.stderr || process.stderr;
|
|
187
|
+
if (args.includes("--help") || args.includes("-h")) {
|
|
188
|
+
checkHelp(stdout);
|
|
189
|
+
return 0;
|
|
190
|
+
}
|
|
191
|
+
const unknown = args.filter((arg) => arg !== "--json");
|
|
192
|
+
if (unknown.length > 0) {
|
|
193
|
+
writeLine(stderr, `luciazero check-update: unknown option '${unknown[0]}'`);
|
|
194
|
+
checkHelp(stderr);
|
|
195
|
+
return 64;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const findInstallations = dependencies.detectInstallations || detectInstallations;
|
|
199
|
+
const getLatestVersion = dependencies.fetchLatestVersion || fetchLatestVersion;
|
|
200
|
+
let latestVersion;
|
|
201
|
+
try {
|
|
202
|
+
latestVersion = await getLatestVersion();
|
|
203
|
+
} catch (error) {
|
|
204
|
+
writeLine(stderr, `luciazero check-update: ${error.message}`);
|
|
205
|
+
writeLine(stderr, "No files were changed. Retry later or check https://www.npmjs.com/package/luciazero.");
|
|
206
|
+
return 1;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const result = buildCheckResult(findInstallations(), latestVersion);
|
|
210
|
+
if (args.includes("--json")) {
|
|
211
|
+
writeLine(stdout, JSON.stringify(result, null, 2));
|
|
212
|
+
return 0;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
writeLine(stdout, "Luciazero update check");
|
|
216
|
+
writeLine(stdout, ` latest npm release ${result.latestVersion}`);
|
|
217
|
+
writeLine(stdout, ` this command carries ${result.bundledVersion}`);
|
|
218
|
+
if (result.installations.length === 0) {
|
|
219
|
+
writeLine(stdout, " no classic Claude/Codex installation detected");
|
|
220
|
+
} else {
|
|
221
|
+
for (const installation of result.installations) {
|
|
222
|
+
const installed = installation.installedVersion || "unknown version";
|
|
223
|
+
const suffix = {
|
|
224
|
+
current: "current",
|
|
225
|
+
"update-available": `update available -> ${result.latestVersion}`,
|
|
226
|
+
ahead: "ahead of the latest public release",
|
|
227
|
+
unknown: `cannot compare -> ${result.latestVersion}`,
|
|
228
|
+
}[installation.status];
|
|
229
|
+
writeLine(stdout, ` ${channelLabel(installation)}: ${installed} (${suffix})`);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
const malformed = result.installations.filter(
|
|
233
|
+
(item) => item.versionFilePresent && item.status === "unknown"
|
|
234
|
+
);
|
|
235
|
+
const updatable = result.installations.filter(
|
|
236
|
+
(item) => item.status === "update-available" || (!item.versionFilePresent && item.status === "unknown")
|
|
237
|
+
);
|
|
238
|
+
if (malformed.length > 0) {
|
|
239
|
+
writeLine(stdout);
|
|
240
|
+
writeLine(stdout, "Cannot update installs with a malformed .luciazero-version file.");
|
|
241
|
+
writeLine(stdout, "Repair it, or remove it only after confirming the installed version.");
|
|
242
|
+
}
|
|
243
|
+
if (updatable.length > 0) {
|
|
244
|
+
writeLine(stdout);
|
|
245
|
+
writeLine(stdout, "Update detected classic/Codex installs with:");
|
|
246
|
+
writeLine(stdout, " npx luciazero@latest update");
|
|
247
|
+
} else if (result.cliUpdateAvailable) {
|
|
248
|
+
writeLine(stdout);
|
|
249
|
+
writeLine(stdout, "This command is older than the latest release. Re-run it with:");
|
|
250
|
+
writeLine(stdout, " npx luciazero@latest check-update");
|
|
251
|
+
}
|
|
252
|
+
writeLine(stdout);
|
|
253
|
+
printSeparateChannelHelp(stdout);
|
|
254
|
+
return 0;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function updateHelp(stream) {
|
|
258
|
+
writeLine(stream, "Usage: npx luciazero@latest update");
|
|
259
|
+
writeLine(stream, "Updates every detected classic Claude/Codex install and preserves Claude hook mode.");
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function runInstaller(installation, dependencies = {}) {
|
|
263
|
+
const spawn = dependencies.spawnSync || spawnSync;
|
|
264
|
+
const script = installation.channel === "codex" ? "install-codex.sh" : "install.sh";
|
|
265
|
+
const args = [path.join(ROOT, script)];
|
|
266
|
+
if (installation.channel === "claude-classic" && installation.hooks) args.push("--with-hooks");
|
|
267
|
+
return spawn("bash", args, { env: process.env, stdio: "inherit" });
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function runUpdate(args = [], dependencies = {}) {
|
|
271
|
+
const stdout = dependencies.stdout || process.stdout;
|
|
272
|
+
const stderr = dependencies.stderr || process.stderr;
|
|
273
|
+
if (args.includes("--help") || args.includes("-h")) {
|
|
274
|
+
updateHelp(stdout);
|
|
275
|
+
return 0;
|
|
276
|
+
}
|
|
277
|
+
if (args.length > 0) {
|
|
278
|
+
writeLine(stderr, `luciazero update: unknown option '${args[0]}'`);
|
|
279
|
+
updateHelp(stderr);
|
|
280
|
+
return 64;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
const findInstallations = dependencies.detectInstallations || detectInstallations;
|
|
284
|
+
const installations = findInstallations();
|
|
285
|
+
if (installations.length === 0) {
|
|
286
|
+
writeLine(stderr, "No classic Claude/Codex installation detected; nothing changed.");
|
|
287
|
+
printSeparateChannelHelp(stderr);
|
|
288
|
+
return 1;
|
|
289
|
+
}
|
|
290
|
+
const newerInstallations = installations.filter(
|
|
291
|
+
(installation) => compareSemver(installation.installedVersion, PACKAGE_VERSION) === 1
|
|
292
|
+
);
|
|
293
|
+
if (newerInstallations.length > 0) {
|
|
294
|
+
for (const installation of newerInstallations) {
|
|
295
|
+
writeLine(
|
|
296
|
+
stderr,
|
|
297
|
+
`Refusing to downgrade ${channelLabel(installation)} from ` +
|
|
298
|
+
`${installation.installedVersion} to bundled ${PACKAGE_VERSION}.`
|
|
299
|
+
);
|
|
300
|
+
}
|
|
301
|
+
writeLine(stderr, "Run npx luciazero@latest update so the updater itself is current.");
|
|
302
|
+
return 1;
|
|
303
|
+
}
|
|
304
|
+
const invalidMetadata = installations.filter(
|
|
305
|
+
(installation) => installation.versionFilePresent && !parseSemver(installation.installedVersion)
|
|
306
|
+
);
|
|
307
|
+
if (invalidMetadata.length > 0) {
|
|
308
|
+
for (const installation of invalidMetadata) {
|
|
309
|
+
writeLine(
|
|
310
|
+
stderr,
|
|
311
|
+
`Refusing to update ${channelLabel(installation)}: .luciazero-version is malformed.`
|
|
312
|
+
);
|
|
313
|
+
}
|
|
314
|
+
writeLine(stderr, "Repair or remove the malformed sidecar after confirming the installed version, then retry.");
|
|
315
|
+
return 1;
|
|
316
|
+
}
|
|
317
|
+
if (process.platform === "win32") {
|
|
318
|
+
writeLine(stderr, "Luciazero installers need Bash. Run this command inside WSL.");
|
|
319
|
+
return 1;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
writeLine(stdout, `Updating detected installations with Luciazero ${PACKAGE_VERSION}:`);
|
|
323
|
+
let failed = false;
|
|
324
|
+
for (const installation of installations) {
|
|
325
|
+
writeLine(stdout, `\n-- ${channelLabel(installation)} (${installation.configDir})`);
|
|
326
|
+
const result = runInstaller(installation, dependencies);
|
|
327
|
+
if (result.error || result.status !== 0) {
|
|
328
|
+
failed = true;
|
|
329
|
+
const detail = result.error ? `: ${result.error.message}` : ` (exit ${result.status})`;
|
|
330
|
+
writeLine(stderr, `luciazero update: ${channelLabel(installation)} failed${detail}`);
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
if (failed) return 1;
|
|
334
|
+
|
|
335
|
+
writeLine(stdout, "\nUpdated all detected classic/Codex installations.");
|
|
336
|
+
writeLine(stdout, "Start a new agent session so the refreshed doctrine and skills are loaded.");
|
|
337
|
+
return 0;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
async function main() {
|
|
341
|
+
const [command, ...args] = process.argv.slice(2);
|
|
342
|
+
let status;
|
|
343
|
+
if (command === "check") status = await runCheck(args);
|
|
344
|
+
else if (command === "update") status = runUpdate(args);
|
|
345
|
+
else {
|
|
346
|
+
writeLine(process.stderr, `luciazero update helper: unknown command '${command || ""}'`);
|
|
347
|
+
status = 64;
|
|
348
|
+
}
|
|
349
|
+
process.exitCode = status;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
if (require.main === module) {
|
|
353
|
+
main().catch((error) => {
|
|
354
|
+
writeLine(process.stderr, `luciazero update helper: ${error.message}`);
|
|
355
|
+
process.exitCode = 1;
|
|
356
|
+
});
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
module.exports = {
|
|
360
|
+
buildCheckResult,
|
|
361
|
+
compareSemver,
|
|
362
|
+
detectInstallations,
|
|
363
|
+
fetchLatestVersion,
|
|
364
|
+
runCheck,
|
|
365
|
+
runUpdate,
|
|
366
|
+
};
|
package/install.sh
CHANGED
|
@@ -183,7 +183,8 @@ echo "Installing into ${CLAUDE_DIR}"
|
|
|
183
183
|
mkdir -p "${CLAUDE_DIR}/skills"
|
|
184
184
|
|
|
185
185
|
# 1. doctrine
|
|
186
|
-
|
|
186
|
+
install_file "${SRC}/claude/${DOCTRINE}" "${CLAUDE_DIR}/${DOCTRINE}" \
|
|
187
|
+
"${MANAGED_DIR}/${DOCTRINE}" "${DOCTRINE}"
|
|
187
188
|
echo " ok ${DOCTRINE}"
|
|
188
189
|
|
|
189
190
|
# 2. skills — catalog.txt is the single install/status/uninstall inventory
|
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"]))
|
package/uninstall.sh
CHANGED
|
@@ -59,9 +59,9 @@ remove_managed_file() {
|
|
|
59
59
|
|
|
60
60
|
echo "Removing from ${CLAUDE_DIR}"
|
|
61
61
|
|
|
62
|
-
|
|
62
|
+
remove_managed_file "${CLAUDE_DIR}/${DOCTRINE}" \
|
|
63
|
+
"${MANAGED_DIR}/${DOCTRINE}" "${SRC}/claude/${DOCTRINE}" "${DOCTRINE}"
|
|
63
64
|
rm -f "${CLAUDE_DIR}/.luciazero-version"
|
|
64
|
-
echo " ok ${DOCTRINE}"
|
|
65
65
|
|
|
66
66
|
while IFS= read -r SKILL; do
|
|
67
67
|
remove_managed_tree "${CLAUDE_DIR}/skills/${SKILL}" \
|