luciazero 2.0.3 → 2.2.0
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 +28 -0
- package/README.md +26 -11
- package/README.th.md +25 -11
- package/bin/discipline-report.js +35 -0
- package/claude/hooks/hooks.json +36 -0
- package/claude/hooks/luciazero-statusline.sh +17 -2
- package/claude/hooks/luciazero-verify.sh +157 -18
- package/claude/luciazero.md +2 -2
- package/install-codex.sh +6 -2
- package/install.sh +17 -7
- package/package.json +2 -2
- package/skills/aliases.txt +2 -0
- package/skills/catalog.txt +3 -1
- package/skills/debug/SKILL.md +1 -1
- package/skills/discipline-report/SKILL.md +3 -1
- package/skills/done/SKILL.md +1 -1
- package/skills/done/scripts/revert-probe.sh +1 -1
- package/skills/imouto-mode/SKILL.md +43 -0
- package/skills/imouto-mode/agents/openai.yaml +6 -0
- package/skills/luciazero-bootstrap/SKILL.md +5 -104
- package/skills/plan/SKILL.md +1 -1
- package/skills/ready/SKILL.md +109 -0
- package/skills/{luciazero-bootstrap → ready}/scripts/detect.sh +1 -1
- package/skills/show/SKILL.md +133 -0
- package/skills/show/agents/openai.yaml +4 -0
- package/uninstall-codex.sh +5 -1
- package/uninstall.sh +6 -2
- /package/skills/{luciazero-bootstrap → ready}/references/smart-verification.md +0 -0
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,34 @@ Versioning: [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [2.2.0] - 2026-08-15
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- `/imouto-mode` adds Lucia's optional warm, lightly tsundere younger-sister
|
|
15
|
+
coding voice. It is explicit-only, off by default, invocation-scoped, and
|
|
16
|
+
keeps technical work, safety, and verification ahead of persona.
|
|
17
|
+
|
|
18
|
+
## [2.1.0] - 2026-08-15
|
|
19
|
+
|
|
20
|
+
### Added
|
|
21
|
+
|
|
22
|
+
- `/show` turns code relationships, structural changes, and verification
|
|
23
|
+
evidence into the smallest useful traceable visual.
|
|
24
|
+
- `./test.sh --fast` provides a measured intermediate tier while the default
|
|
25
|
+
and `--full` preserve complete CI/closeout coverage.
|
|
26
|
+
- Opt-in Claude hooks use private per-session scratch state to record
|
|
27
|
+
privacy-preserving aggregate turn/merged-Bash wall time and Bash, failed or
|
|
28
|
+
successful verify, and model/user skill counts; `luciazero discipline`
|
|
29
|
+
summarizes them.
|
|
30
|
+
|
|
31
|
+
### Changed
|
|
32
|
+
|
|
33
|
+
- `/luciazero-bootstrap` is now `/ready`. The old command remains as a
|
|
34
|
+
deprecated compatibility alias for one release.
|
|
35
|
+
- The doctrine reserves full verification for closeout, and `/plan` plus
|
|
36
|
+
`/debug` no longer auto-trigger for routine edits or first obvious failures.
|
|
37
|
+
|
|
10
38
|
## [2.0.3] - 2026-08-13
|
|
11
39
|
|
|
12
40
|
### Fixed
|
package/README.md
CHANGED
|
@@ -16,7 +16,7 @@ of handing back work they merely believe is finished.
|
|
|
16
16
|
> Done is proven by a command, not by my judgment. If no verification command
|
|
17
17
|
> exists, that is the first bug.
|
|
18
18
|
|
|
19
|
-
It ships a compact [9-rule doctrine](claude/luciazero.md),
|
|
19
|
+
It ships a compact [9-rule doctrine](claude/luciazero.md), 11 on-demand skills,
|
|
20
20
|
verification hooks, a risk-routed reviewer, and an eval harness. It is a
|
|
21
21
|
discipline layer, not an agent runtime or overnight orchestrator.
|
|
22
22
|
|
|
@@ -83,7 +83,7 @@ Carries the doctrine, all skills, reviewer, and verify-tracking hooks:
|
|
|
83
83
|
/plugin install luciazero@luciazero
|
|
84
84
|
```
|
|
85
85
|
|
|
86
|
-
Start a repository with `/luciazero:
|
|
86
|
+
Start a repository with `/luciazero:ready`. Plugin skills use the
|
|
87
87
|
`/luciazero:` prefix. The plugin has no statusline because Claude Code plugins
|
|
88
88
|
cannot set one.
|
|
89
89
|
|
|
@@ -93,7 +93,8 @@ cannot set one.
|
|
|
93
93
|
npx skills add ohm41321/luciazero
|
|
94
94
|
```
|
|
95
95
|
|
|
96
|
-
This installs the
|
|
96
|
+
This installs the 11 skills plus the temporary `/luciazero-bootstrap`
|
|
97
|
+
compatibility alias: no doctrine, reviewer, or hooks.
|
|
97
98
|
|
|
98
99
|
### Classic Claude Code and Codex
|
|
99
100
|
|
|
@@ -140,14 +141,16 @@ Claude Code can auto-update the plugin at startup: open `/plugin` →
|
|
|
140
141
|
marketplaces leave this off by default. For release-only notifications, use
|
|
141
142
|
GitHub **Watch → Custom → Releases**.
|
|
142
143
|
|
|
143
|
-
## The
|
|
144
|
+
## The 11 skills
|
|
144
145
|
|
|
145
|
-
Run `/
|
|
146
|
+
Run `/ready` first; the rest activate when their moment arrives.
|
|
146
147
|
|
|
147
148
|
| Moment | Skill | Result |
|
|
148
149
|
|---|---|---|
|
|
149
|
-
| Entering a repository | `/
|
|
150
|
-
|
|
|
150
|
+
| Entering a repository | `/ready` | Finds or creates a verify command and proves it can fail |
|
|
151
|
+
| Structure or evidence is hard to scan | `/show` | Maps connections, changes, and proof into the smallest useful visual |
|
|
152
|
+
| Want Lucia's optional coding voice | `/imouto-mode focus` | Adds a warm, lightly tsundere sibling voice; explicit-only and off by default |
|
|
153
|
+
| Before risky, ambiguous, or multi-module work | `/plan` | Fixes scope and observable acceptance evidence |
|
|
151
154
|
| A bug survives the first look | `/debug` | Reproduction, hypothesis ledger, regression test |
|
|
152
155
|
| Good and bad revisions are known | `/bisect` | Finds the first bad commit in a temporary worktree |
|
|
153
156
|
| Before claiming completion | `/done` | Full verify, skeptic review, scope report |
|
|
@@ -156,6 +159,11 @@ Run `/luciazero-bootstrap` first; the rest activate when their moment arrives.
|
|
|
156
159
|
| Reviewing local verify habits | `/discipline-report` | Time/project-filtered local outcome report |
|
|
157
160
|
| After difficult work | `/retro` | Stores reusable lessons and disproved approaches |
|
|
158
161
|
|
|
162
|
+
`/imouto-mode` never activates itself. Use `focus` (recommended), `on`, or
|
|
163
|
+
`off`; the mode applies only to that invocation, writes no config, and leaves
|
|
164
|
+
technical evidence plain. Plugin users invoke `/luciazero:imouto-mode focus`;
|
|
165
|
+
Codex users invoke `$imouto-mode focus`.
|
|
166
|
+
|
|
159
167
|
Risky diffs also pass through one read-only `reviewer` with `security`,
|
|
160
168
|
`contract`, or `general` focus. Security and contract risk together receive two
|
|
161
169
|
separate passes.
|
|
@@ -205,6 +213,9 @@ only one run per arm per task. See the [full benchmark](docs/benchmark.md),
|
|
|
205
213
|
- Core installers, hooks, helpers, and graders are offline. Real behavioral
|
|
206
214
|
evals invoke a model CLI and consume API credit or subscription quota.
|
|
207
215
|
- Hooks run commands on your machine. Read them before enabling them.
|
|
216
|
+
- Hook telemetry stays local in private per-session state and records aggregate
|
|
217
|
+
turn/Bash wall time plus Bash, verify, and model/user skill counts—never raw
|
|
218
|
+
commands, skill names, or paths.
|
|
208
219
|
- Set `LUCIAZERO_VERIFY_CMD` to the repo's exact fast verify command.
|
|
209
220
|
- Put `LUCIAZERO_STRICT_VERIFY_CMD` only in personal settings, never in a
|
|
210
221
|
committed repository config. Strict mode fails open on internal errors.
|
|
@@ -214,12 +225,16 @@ See [SECURITY.md](SECURITY.md) for the complete trust boundary.
|
|
|
214
225
|
## Development
|
|
215
226
|
|
|
216
227
|
```bash
|
|
217
|
-
./test.sh
|
|
228
|
+
./test.sh --fast # intermediate loop: core doctrine/hooks/report/Relay checks
|
|
229
|
+
./test.sh # closeout/CI: full eval, packaging, and install coverage
|
|
218
230
|
```
|
|
219
231
|
|
|
220
|
-
The
|
|
221
|
-
|
|
222
|
-
|
|
232
|
+
The fast tier is the default intermediate check for this repository; use a
|
|
233
|
+
more targeted command when changing a component it does not cover. The default
|
|
234
|
+
full tier (also `./test.sh --full`) covers scripts, hook state, Relay, bisect,
|
|
235
|
+
plugin/npm manifests, self-proving eval graders, and sandboxed install →
|
|
236
|
+
reinstall → uninstall for Claude Code and Codex. CI and `/done` use the full
|
|
237
|
+
tier.
|
|
223
238
|
|
|
224
239
|
More detail:
|
|
225
240
|
|
package/README.th.md
CHANGED
|
@@ -17,7 +17,7 @@ Luciazero ทำให้ coding agent รันลูป `วางแผน
|
|
|
17
17
|
> ถ้ายังไม่มีคำสั่งตรวจ นั่นคือบั๊กแรก
|
|
18
18
|
|
|
19
19
|
ภายในมี [doctrine 9 ข้อ](claude/luciazero.md) ที่สั้น, skill แบบเรียกเมื่อจำเป็น
|
|
20
|
-
|
|
20
|
+
11 ตัว, hook ติดตามการ verify, reviewer ที่ route ตามความเสี่ยง และ eval harness
|
|
21
21
|
นี่คือชั้นวินัย ไม่ใช่ agent runtime หรือระบบ orchestration สำหรับรันงานข้ามคืน
|
|
22
22
|
|
|
23
23
|
## ดูการทำงานใน 15 วินาที
|
|
@@ -81,7 +81,7 @@ fingerprint เก่า (5/6) ตัวเลขเหล่านี้เป
|
|
|
81
81
|
/plugin install luciazero@luciazero
|
|
82
82
|
```
|
|
83
83
|
|
|
84
|
-
เริ่ม repo ด้วย `/luciazero:
|
|
84
|
+
เริ่ม repo ด้วย `/luciazero:ready` ชื่อ skill แบบ plugin มี prefix
|
|
85
85
|
`/luciazero:` และไม่มี statusline เพราะ Claude Code plugin ตั้งค่านี้ไม่ได้
|
|
86
86
|
|
|
87
87
|
### เฉพาะ skill — agent ที่รองรับ
|
|
@@ -90,7 +90,8 @@ fingerprint เก่า (5/6) ตัวเลขเหล่านี้เป
|
|
|
90
90
|
npx skills add ohm41321/luciazero
|
|
91
91
|
```
|
|
92
92
|
|
|
93
|
-
ช่องทางนี้ติดตั้งเฉพาะ skill
|
|
93
|
+
ช่องทางนี้ติดตั้งเฉพาะ skill 11 ตัว พร้อม alias `/luciazero-bootstrap`
|
|
94
|
+
ชั่วคราว ไม่มี doctrine, reviewer หรือ hook
|
|
94
95
|
|
|
95
96
|
### Classic Claude Code และ Codex
|
|
96
97
|
|
|
@@ -136,14 +137,16 @@ Claude Code อัปเดต plugin ตอนเริ่มโปรแกร
|
|
|
136
137
|
ภายนอกจะปิดตัวเลือกนี้เป็นค่าเริ่มต้น ถ้าต้องการเพียงการแจ้งเตือน release ให้ใช้
|
|
137
138
|
GitHub **Watch → Custom → Releases**
|
|
138
139
|
|
|
139
|
-
## Skill ทั้ง
|
|
140
|
+
## Skill ทั้ง 11 ตัว
|
|
140
141
|
|
|
141
|
-
รัน `/
|
|
142
|
+
รัน `/ready` ก่อนหนึ่งครั้ง ที่เหลือใช้เมื่อถึงจังหวะของมัน
|
|
142
143
|
|
|
143
144
|
| จังหวะ | Skill | ผลลัพธ์ |
|
|
144
145
|
|---|---|---|
|
|
145
|
-
| เข้า repository | `/
|
|
146
|
-
|
|
|
146
|
+
| เข้า repository | `/ready` | หาหรือสร้างคำสั่ง verify และพิสูจน์ว่าแดงได้ |
|
|
147
|
+
| โครงสร้างหรือหลักฐานไล่อ่านยาก | `/show` | แสดงความเชื่อมโยง สิ่งที่เปลี่ยน และหลักฐานด้วยภาพที่เล็กที่สุด |
|
|
148
|
+
| อยากได้เสียงพูดแบบลูเซียระหว่างเขียนโค้ด | `/imouto-mode focus` | เพิ่มน้ำเสียงน้องสาวซึนเดเระแบบอ่อน ๆ; ต้องเปิดเองและค่าเริ่มต้นปิด |
|
|
149
|
+
| ก่อนงานเสี่ยง กำกวม หรือแตะหลาย module | `/plan` | ล็อก scope และหลักฐานยอมรับที่สังเกตได้ |
|
|
147
150
|
| บั๊กที่มองรอบแรกไม่ออก | `/debug` | Reproduction, hypothesis ledger, regression test |
|
|
148
151
|
| รู้ revision ดีและเสีย | `/bisect` | หา first bad commit ใน worktree ชั่วคราว |
|
|
149
152
|
| ก่อนบอกว่าเสร็จ | `/done` | Full verify, skeptic review และรายงาน scope |
|
|
@@ -152,6 +155,11 @@ GitHub **Watch → Custom → Releases**
|
|
|
152
155
|
| ดูนิสัยการ verify ในเครื่อง | `/discipline-report` | รายงาน outcome กรองตามเวลา/โปรเจกต์ |
|
|
153
156
|
| หลังงานยาก | `/retro` | เก็บบทเรียนและแนวทางที่พิสูจน์แล้วว่าไม่เวิร์ก |
|
|
154
157
|
|
|
158
|
+
`/imouto-mode` จะไม่เปิดตัวเอง ใช้ `focus` (แนะนำ), `on` หรือ `off` โดยโหมดมีผล
|
|
159
|
+
เฉพาะ invocation นั้น ไม่เขียน config และหลักฐานทางเทคนิคจะใช้ภาษาตรงเสมอ
|
|
160
|
+
ผู้ใช้ plugin เรียก `/luciazero:imouto-mode focus`; ผู้ใช้ Codex เรียก
|
|
161
|
+
`$imouto-mode focus`
|
|
162
|
+
|
|
155
163
|
Diff เสี่ยงจะผ่าน `reviewer` แบบอ่านอย่างเดียวใน focus `security`, `contract`
|
|
156
164
|
หรือ `general` ถ้าเสี่ยงทั้ง security และ contract จะตรวจแยกสองรอบ
|
|
157
165
|
|
|
@@ -200,6 +208,9 @@ pilot มีเพียง 1 run ต่อ arm ต่อ task ดู [ผลเ
|
|
|
200
208
|
- Installer, hook, helper และ grader หลักรัน offline ส่วน behavioral eval จริง
|
|
201
209
|
เรียก model CLI และใช้เครดิต API หรือโควตา subscription
|
|
202
210
|
- Hook รันคำสั่งบนเครื่อง ควรอ่านก่อนเปิดใช้
|
|
211
|
+
- Telemetry ของ hook อยู่ใน private state แยกตาม session ภายในเครื่อง เก็บเวลา
|
|
212
|
+
wall time ของ turn/Bash และจำนวน Bash, verify, skill ที่ model/user เรียก
|
|
213
|
+
โดยไม่เก็บ command, ชื่อ skill หรือ path ดิบ
|
|
203
214
|
- ตั้ง `LUCIAZERO_VERIFY_CMD` เป็นคำสั่ง verify ระดับเร็วที่ exact ของ repo
|
|
204
215
|
- ใส่ `LUCIAZERO_STRICT_VERIFY_CMD` ใน personal settings เท่านั้น ห้าม commit ลง
|
|
205
216
|
config ของ repository; strict mode จะ fail open เมื่อเกิด internal error
|
|
@@ -209,12 +220,15 @@ pilot มีเพียง 1 run ต่อ arm ต่อ task ดู [ผลเ
|
|
|
209
220
|
## พัฒนา repo นี้
|
|
210
221
|
|
|
211
222
|
```bash
|
|
212
|
-
./test.sh
|
|
223
|
+
./test.sh --fast # loop ระหว่างทำ: ตรวจ doctrine/hook/report/Relay ส่วนหลัก
|
|
224
|
+
./test.sh # ปิดงาน/CI: ตรวจ eval, packaging และ install แบบเต็ม
|
|
213
225
|
```
|
|
214
226
|
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
227
|
+
fast tier เป็นคำสั่งระหว่างทำงานของ repo นี้; ถ้าแก้ส่วนที่ fast tier ไม่ครอบคลุม
|
|
228
|
+
ให้ใช้คำสั่ง targeted ของส่วนนั้น ส่วน full tier (`./test.sh` หรือ
|
|
229
|
+
`./test.sh --full`) ครอบคลุม script, state ของ hook, Relay, bisect, manifest ของ
|
|
230
|
+
plugin/npm, eval grader ที่พิสูจน์ตัวเองได้ และ install → reinstall → uninstall
|
|
231
|
+
แบบ sandbox ทั้ง Claude Code และ Codex โดย CI และ `/done` ใช้ full tier
|
|
218
232
|
|
|
219
233
|
อ่านต่อ:
|
|
220
234
|
|
package/bin/discipline-report.js
CHANGED
|
@@ -56,12 +56,20 @@ function parseLine(line) {
|
|
|
56
56
|
if (Number.isNaN(timestamp.valueOf()) || typeof row.project_id !== "string" || typeof row.project !== "string") {
|
|
57
57
|
return { malformed: true };
|
|
58
58
|
}
|
|
59
|
+
let telemetry = null;
|
|
60
|
+
if (row.telemetry && typeof row.telemetry === "object") {
|
|
61
|
+
const keys = ["turn_ms", "bash_ms", "bash_count", "verify_count", "skill_count"];
|
|
62
|
+
if (keys.every((key) => Number.isSafeInteger(row.telemetry[key]) && row.telemetry[key] >= 0)) {
|
|
63
|
+
telemetry = Object.fromEntries(keys.map((key) => [key, row.telemetry[key]]));
|
|
64
|
+
}
|
|
65
|
+
}
|
|
59
66
|
return {
|
|
60
67
|
timestamp,
|
|
61
68
|
event: row.event,
|
|
62
69
|
project: row.project,
|
|
63
70
|
projectId: row.project_id,
|
|
64
71
|
verifyMode: ["regex", "exact", "strict"].includes(row.verify_mode) ? row.verify_mode : "unknown",
|
|
72
|
+
telemetry,
|
|
65
73
|
legacy: false,
|
|
66
74
|
};
|
|
67
75
|
}
|
|
@@ -75,6 +83,7 @@ function parseLine(line) {
|
|
|
75
83
|
project: match[3],
|
|
76
84
|
projectId: `legacy-${crypto.createHash("sha256").update(match[3]).digest("hex").slice(0, 12)}`,
|
|
77
85
|
verifyMode: "unknown",
|
|
86
|
+
telemetry: null,
|
|
78
87
|
legacy: true,
|
|
79
88
|
};
|
|
80
89
|
}
|
|
@@ -111,10 +120,23 @@ if (options.project) {
|
|
|
111
120
|
|
|
112
121
|
const counts = { "stop-clean": 0, nudge: 0, "strict-block": 0 };
|
|
113
122
|
const modes = { regex: 0, exact: 0, strict: 0, unknown: 0 };
|
|
123
|
+
const telemetry = {
|
|
124
|
+
measured_turns: 0, turn_ms: 0, bash_ms: 0, non_bash_ms: 0,
|
|
125
|
+
bash_count: 0, verify_count: 0, skill_count: 0,
|
|
126
|
+
};
|
|
114
127
|
const projects = new Map();
|
|
115
128
|
for (const row of rows) {
|
|
116
129
|
counts[row.event] += 1;
|
|
117
130
|
modes[row.verifyMode] = (modes[row.verifyMode] || 0) + 1;
|
|
131
|
+
if (row.telemetry) {
|
|
132
|
+
telemetry.measured_turns += 1;
|
|
133
|
+
telemetry.turn_ms += row.telemetry.turn_ms;
|
|
134
|
+
telemetry.bash_ms += row.telemetry.bash_ms;
|
|
135
|
+
telemetry.non_bash_ms += Math.max(0, row.telemetry.turn_ms - row.telemetry.bash_ms);
|
|
136
|
+
telemetry.bash_count += row.telemetry.bash_count;
|
|
137
|
+
telemetry.verify_count += row.telemetry.verify_count;
|
|
138
|
+
telemetry.skill_count += row.telemetry.skill_count;
|
|
139
|
+
}
|
|
118
140
|
const current = projects.get(row.projectId) || {
|
|
119
141
|
project: row.project,
|
|
120
142
|
project_id: row.projectId,
|
|
@@ -160,6 +182,7 @@ const report = {
|
|
|
160
182
|
legacy_records: legacyRecords,
|
|
161
183
|
outcomes: counts,
|
|
162
184
|
verify_modes: modes,
|
|
185
|
+
telemetry,
|
|
163
186
|
top_nudged_projects: topNudged,
|
|
164
187
|
recommendations,
|
|
165
188
|
};
|
|
@@ -182,6 +205,18 @@ console.log(` Clean stops: ${String(counts["stop-clean"]).padStart(5)} (${per
|
|
|
182
205
|
console.log(` Nudges: ${String(counts.nudge).padStart(5)} (${percent(counts.nudge)})`);
|
|
183
206
|
console.log(` Strict blocks: ${String(counts["strict-block"]).padStart(5)} (${percent(counts["strict-block"])})`);
|
|
184
207
|
console.log("");
|
|
208
|
+
console.log("Latency Telemetry:");
|
|
209
|
+
if (telemetry.measured_turns === 0) {
|
|
210
|
+
console.log(" No measured turns (new hooks collect this locally).");
|
|
211
|
+
} else {
|
|
212
|
+
const average = (value) => Math.round(value / telemetry.measured_turns);
|
|
213
|
+
console.log(` Measured turns: ${telemetry.measured_turns}`);
|
|
214
|
+
console.log(` Average turn: ${average(telemetry.turn_ms)} ms`);
|
|
215
|
+
console.log(` Average Bash time: ${average(telemetry.bash_ms)} ms`);
|
|
216
|
+
console.log(` Average non-Bash: ${average(telemetry.non_bash_ms)} ms`);
|
|
217
|
+
console.log(` Bash / verify / skill calls: ${telemetry.bash_count} / ${telemetry.verify_count} / ${telemetry.skill_count}`);
|
|
218
|
+
}
|
|
219
|
+
console.log("");
|
|
185
220
|
console.log("Top Nudged Repositories:");
|
|
186
221
|
if (topNudged.length === 0) console.log(" None in the selected period.");
|
|
187
222
|
for (const [index, item] of topNudged.entries()) {
|
package/claude/hooks/hooks.json
CHANGED
|
@@ -1,5 +1,27 @@
|
|
|
1
1
|
{
|
|
2
2
|
"hooks": {
|
|
3
|
+
"UserPromptSubmit": [
|
|
4
|
+
{
|
|
5
|
+
"hooks": [
|
|
6
|
+
{ "type": "command", "command": "LUCIAZERO_CHANNEL=plugin ${CLAUDE_PLUGIN_ROOT}/claude/hooks/luciazero-verify.sh prompt" }
|
|
7
|
+
]
|
|
8
|
+
}
|
|
9
|
+
],
|
|
10
|
+
"UserPromptExpansion": [
|
|
11
|
+
{
|
|
12
|
+
"hooks": [
|
|
13
|
+
{ "type": "command", "command": "LUCIAZERO_CHANNEL=plugin ${CLAUDE_PLUGIN_ROOT}/claude/hooks/luciazero-verify.sh skill-prompt" }
|
|
14
|
+
]
|
|
15
|
+
}
|
|
16
|
+
],
|
|
17
|
+
"PreToolUse": [
|
|
18
|
+
{
|
|
19
|
+
"matcher": "Bash",
|
|
20
|
+
"hooks": [
|
|
21
|
+
{ "type": "command", "command": "LUCIAZERO_CHANNEL=plugin ${CLAUDE_PLUGIN_ROOT}/claude/hooks/luciazero-verify.sh bash-start" }
|
|
22
|
+
]
|
|
23
|
+
}
|
|
24
|
+
],
|
|
3
25
|
"PostToolUse": [
|
|
4
26
|
{
|
|
5
27
|
"matcher": "Edit|Write|NotebookEdit",
|
|
@@ -12,6 +34,20 @@
|
|
|
12
34
|
"hooks": [
|
|
13
35
|
{ "type": "command", "command": "LUCIAZERO_CHANNEL=plugin ${CLAUDE_PLUGIN_ROOT}/claude/hooks/luciazero-verify.sh bash" }
|
|
14
36
|
]
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
"matcher": "Skill",
|
|
40
|
+
"hooks": [
|
|
41
|
+
{ "type": "command", "command": "LUCIAZERO_CHANNEL=plugin ${CLAUDE_PLUGIN_ROOT}/claude/hooks/luciazero-verify.sh skill" }
|
|
42
|
+
]
|
|
43
|
+
}
|
|
44
|
+
],
|
|
45
|
+
"PostToolUseFailure": [
|
|
46
|
+
{
|
|
47
|
+
"matcher": "Bash",
|
|
48
|
+
"hooks": [
|
|
49
|
+
{ "type": "command", "command": "LUCIAZERO_CHANNEL=plugin ${CLAUDE_PLUGIN_ROOT}/claude/hooks/luciazero-verify.sh bash-failure" }
|
|
50
|
+
]
|
|
15
51
|
}
|
|
16
52
|
],
|
|
17
53
|
"Stop": [
|
|
@@ -16,7 +16,7 @@ set -u
|
|
|
16
16
|
IN="$(cat 2>/dev/null || true)"
|
|
17
17
|
|
|
18
18
|
LINE="$(printf '%s' "${IN}" | python3 -c '
|
|
19
|
-
import json, os, sys, time, hashlib
|
|
19
|
+
import json, os, sys, time, hashlib, stat
|
|
20
20
|
|
|
21
21
|
try:
|
|
22
22
|
d = json.load(sys.stdin)
|
|
@@ -27,9 +27,24 @@ model = ((d.get("model") or {}).get("display_name")) or "claude"
|
|
|
27
27
|
cwd = ((d.get("workspace") or {}).get("current_dir")) or d.get("cwd") or os.getcwd()
|
|
28
28
|
|
|
29
29
|
key = hashlib.md5(cwd.encode()).hexdigest()[:12]
|
|
30
|
-
|
|
30
|
+
uid = os.getuid() if hasattr(os, "getuid") else "unknown"
|
|
31
|
+
base = os.path.join(os.environ.get("TMPDIR", "/tmp"), f"luciazero-verify-state-{uid}")
|
|
32
|
+
try:
|
|
33
|
+
info = os.lstat(base)
|
|
34
|
+
if stat.S_ISLNK(info.st_mode) or not stat.S_ISDIR(info.st_mode):
|
|
35
|
+
raise OSError("unsafe state base")
|
|
36
|
+
if hasattr(os, "getuid") and info.st_uid != os.getuid():
|
|
37
|
+
raise OSError("wrong state owner")
|
|
38
|
+
if stat.S_IMODE(info.st_mode) & 0o077:
|
|
39
|
+
raise OSError("state base is not private")
|
|
40
|
+
except OSError:
|
|
41
|
+
state = None
|
|
42
|
+
else:
|
|
43
|
+
state = os.path.join(base, key)
|
|
31
44
|
|
|
32
45
|
def mtime(name):
|
|
46
|
+
if state is None:
|
|
47
|
+
return None
|
|
33
48
|
try:
|
|
34
49
|
return os.path.getmtime(os.path.join(state, name))
|
|
35
50
|
except OSError:
|
|
@@ -5,8 +5,13 @@
|
|
|
5
5
|
# ("done is proven by a command") at the exact moment it is most violated.
|
|
6
6
|
#
|
|
7
7
|
# Subcommands (wired in settings.json):
|
|
8
|
+
# prompt — UserPromptSubmit: start privacy-preserving turn telemetry
|
|
9
|
+
# bash-start — PreToolUse on Bash: start shell-command timing
|
|
8
10
|
# edit — PostToolUse on Edit|Write|NotebookEdit : record "an edit happened"
|
|
9
|
-
# bash — PostToolUse on Bash
|
|
11
|
+
# bash — PostToolUse on Bash: record duration, verify runs, and status
|
|
12
|
+
# bash-failure — PostToolUseFailure on Bash: record failed commands
|
|
13
|
+
# skill — PostToolUse on Skill: count model-invoked skills
|
|
14
|
+
# skill-prompt — UserPromptExpansion: count user-invoked slash skills
|
|
10
15
|
# stop — Stop : warn once if edits are unverified
|
|
11
16
|
# session — SessionStart : point at an existing Lucia Relay
|
|
12
17
|
# doctrine— SessionStart (plugin installs only) : emit the doctrine as context
|
|
@@ -29,8 +34,8 @@
|
|
|
29
34
|
# stop hook appends one schema-versioned JSON line per stop outcome
|
|
30
35
|
# (stop-clean / nudge / strict-block) to luciazero-stats.log in the harness
|
|
31
36
|
# config dir — local only, capped at ~250 lines, fail-open. It records a
|
|
32
|
-
# privacy-preserving project hash
|
|
33
|
-
# command. Uninstall keeps it
|
|
37
|
+
# privacy-preserving project hash, verify mode, and aggregate latency/counts;
|
|
38
|
+
# never the project path, command, or skill name. Uninstall keeps it.
|
|
34
39
|
set -u
|
|
35
40
|
|
|
36
41
|
MODE="${1:-}"
|
|
@@ -79,8 +84,54 @@ CWD="$(pyfield "d.get('cwd')")"
|
|
|
79
84
|
[ -n "${CWD}" ] || CWD="${PWD}"
|
|
80
85
|
KEY="$(printf '%s' "${CWD}" | python3 -c 'import sys,hashlib;print(hashlib.md5(sys.stdin.buffer.read()).hexdigest()[:12])' 2>/dev/null)" || exit 0
|
|
81
86
|
[ -n "${KEY}" ] || exit 0
|
|
82
|
-
|
|
87
|
+
BASE="${TMPDIR:-/tmp}/luciazero-verify-state-$(id -u 2>/dev/null || echo unknown)"
|
|
88
|
+
# The base name is predictable, so validate ownership/type before touching it.
|
|
89
|
+
# A hostile pre-created symlink or directory makes the hook fail open.
|
|
90
|
+
python3 - "${BASE}" <<'PY' 2>/dev/null || exit 0
|
|
91
|
+
import os, stat, sys
|
|
92
|
+
path = sys.argv[1]
|
|
93
|
+
try:
|
|
94
|
+
info = os.lstat(path)
|
|
95
|
+
except FileNotFoundError:
|
|
96
|
+
os.mkdir(path, 0o700)
|
|
97
|
+
info = os.lstat(path)
|
|
98
|
+
if not stat.S_ISDIR(info.st_mode) or stat.S_ISLNK(info.st_mode):
|
|
99
|
+
raise SystemExit(1)
|
|
100
|
+
if hasattr(os, "getuid") and info.st_uid != os.getuid():
|
|
101
|
+
raise SystemExit(1)
|
|
102
|
+
os.chmod(path, 0o700)
|
|
103
|
+
PY
|
|
104
|
+
STATE="${BASE}/${KEY}"
|
|
83
105
|
mkdir -p "${STATE}" 2>/dev/null || exit 0
|
|
106
|
+
chmod 700 "${STATE}" 2>/dev/null || exit 0
|
|
107
|
+
SESSION_RAW="$(pyfield "d.get('session_id')")"
|
|
108
|
+
[ -n "${SESSION_RAW}" ] || SESSION_RAW="parent-${PPID}"
|
|
109
|
+
SESSION_KEY="$(printf '%s' "${SESSION_RAW}" | python3 -c 'import hashlib,sys; print(hashlib.sha256(sys.stdin.buffer.read()).hexdigest()[:16])' 2>/dev/null)" || exit 0
|
|
110
|
+
TELEMETRY="${STATE}/telemetry/${SESSION_KEY}"
|
|
111
|
+
|
|
112
|
+
tool_key() { # stable opaque key; raw tool input never leaves temporary state
|
|
113
|
+
RAW="$(pyfield "d.get('tool_use_id') or d.get('tool_input', {}).get('command') or d.get('tool_input', {}).get('skill') or d.get('command_name') or d.get('prompt') or d.get('command')")"
|
|
114
|
+
[ -n "${RAW}" ] || RAW=unknown
|
|
115
|
+
printf '%s' "${RAW}" | python3 -c 'import hashlib,sys; print(hashlib.sha256(sys.stdin.buffer.read()).hexdigest()[:16])' 2>/dev/null
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
now_ms() {
|
|
119
|
+
python3 -c 'import time; print(int(time.time() * 1000))' 2>/dev/null
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
record_strict_telemetry() { # record_strict_telemetry <start-ms>
|
|
123
|
+
STRICT_END_MS="$(now_ms || true)"
|
|
124
|
+
case "${1:-}:${STRICT_END_MS}" in
|
|
125
|
+
*[!0-9:]*|:|*:|*::* ) return ;;
|
|
126
|
+
esac
|
|
127
|
+
[ "${STRICT_END_MS}" -ge "$1" ] 2>/dev/null || return
|
|
128
|
+
mkdir -p "${TELEMETRY}/bash_count" "${TELEMETRY}/bash_intervals" \
|
|
129
|
+
"${TELEMETRY}/verify_count" 2>/dev/null || return
|
|
130
|
+
: > "${TELEMETRY}/bash_count/strict-gate" 2>/dev/null || true
|
|
131
|
+
: > "${TELEMETRY}/verify_count/strict-gate" 2>/dev/null || true
|
|
132
|
+
printf '%s %s\n' "$1" "${STRICT_END_MS}" \
|
|
133
|
+
> "${TELEMETRY}/bash_intervals/strict-gate" 2>/dev/null || true
|
|
134
|
+
}
|
|
84
135
|
|
|
85
136
|
stat_log() { # stat_log <event> — discipline stats; fail-open, capped
|
|
86
137
|
SDIR="${CLAUDE_CONFIG_DIR:-${HOME:-}/.claude}"
|
|
@@ -88,9 +139,9 @@ stat_log() { # stat_log <event> — discipline stats; fail-open, capped
|
|
|
88
139
|
VMODE=regex
|
|
89
140
|
[ -n "${VERIFY_CMD:-}" ] && VMODE=exact
|
|
90
141
|
[ -n "${LUCIAZERO_STRICT_VERIFY_CMD:-}" ] && VMODE=strict
|
|
91
|
-
python3 - "${SFILE}" "${CWD}" "$1" "${VMODE}" <<'PY' 2>/dev/null || true
|
|
142
|
+
python3 - "${SFILE}" "${CWD}" "$1" "${VMODE}" "${TELEMETRY}" <<'PY' 2>/dev/null || true
|
|
92
143
|
import datetime, hashlib, json, os, sys
|
|
93
|
-
path, cwd, event, mode = sys.argv[1:]
|
|
144
|
+
path, cwd, event, mode, telemetry_dir = sys.argv[1:]
|
|
94
145
|
os.makedirs(os.path.dirname(path), exist_ok=True)
|
|
95
146
|
real = os.path.realpath(cwd)
|
|
96
147
|
row = {
|
|
@@ -101,6 +152,47 @@ row = {
|
|
|
101
152
|
"project": os.path.basename(real) or "(root)",
|
|
102
153
|
"verify_mode": mode,
|
|
103
154
|
}
|
|
155
|
+
def read_int(path):
|
|
156
|
+
try:
|
|
157
|
+
value = int(open(path, encoding="utf-8").read().strip())
|
|
158
|
+
return value if value >= 0 else None
|
|
159
|
+
except (OSError, ValueError):
|
|
160
|
+
return None
|
|
161
|
+
def count_files(name):
|
|
162
|
+
try:
|
|
163
|
+
return sum(os.path.isfile(os.path.join(telemetry_dir, name, item))
|
|
164
|
+
for item in os.listdir(os.path.join(telemetry_dir, name)))
|
|
165
|
+
except OSError:
|
|
166
|
+
return 0
|
|
167
|
+
start = read_int(os.path.join(telemetry_dir, "turn_start_ms"))
|
|
168
|
+
if start is not None:
|
|
169
|
+
now = int(datetime.datetime.now(datetime.timezone.utc).timestamp() * 1000)
|
|
170
|
+
intervals = []
|
|
171
|
+
try:
|
|
172
|
+
interval_dir = os.path.join(telemetry_dir, "bash_intervals")
|
|
173
|
+
for item in os.listdir(interval_dir):
|
|
174
|
+
try:
|
|
175
|
+
a, b = map(int, open(os.path.join(interval_dir, item), encoding="utf-8").read().split())
|
|
176
|
+
except (OSError, ValueError):
|
|
177
|
+
continue
|
|
178
|
+
if 0 <= a <= b:
|
|
179
|
+
intervals.append((max(start, a), min(now, b)))
|
|
180
|
+
except OSError:
|
|
181
|
+
pass
|
|
182
|
+
merged = []
|
|
183
|
+
for a, b in sorted((a, b) for a, b in intervals if a <= b):
|
|
184
|
+
if not merged or a > merged[-1][1]:
|
|
185
|
+
merged.append([a, b])
|
|
186
|
+
else:
|
|
187
|
+
merged[-1][1] = max(merged[-1][1], b)
|
|
188
|
+
bash_ms = sum(b - a for a, b in merged)
|
|
189
|
+
row["telemetry"] = {
|
|
190
|
+
"turn_ms": max(0, now - start),
|
|
191
|
+
"bash_ms": bash_ms,
|
|
192
|
+
"bash_count": count_files("bash_count"),
|
|
193
|
+
"verify_count": count_files("verify_count"),
|
|
194
|
+
"skill_count": count_files("skill_count"),
|
|
195
|
+
}
|
|
104
196
|
with open(path, "a", encoding="utf-8") as handle:
|
|
105
197
|
handle.write(json.dumps(row, separators=(",", ":")) + "\n")
|
|
106
198
|
with open(path, encoding="utf-8", errors="replace") as handle:
|
|
@@ -123,6 +215,18 @@ VERIFY_RE="${LUCIAZERO_VERIFY_REGEX:-verify|test\.sh|pytest|npm (run )?test|pnpm
|
|
|
123
215
|
VERIFY_CMD="${LUCIAZERO_VERIFY_CMD:-}"
|
|
124
216
|
|
|
125
217
|
case "${MODE}" in
|
|
218
|
+
prompt)
|
|
219
|
+
# Per-turn scratch data is ephemeral. Persistent rows keep aggregates only.
|
|
220
|
+
rm -rf "${TELEMETRY}" 2>/dev/null || exit 0
|
|
221
|
+
mkdir -p "${TELEMETRY}" 2>/dev/null || exit 0
|
|
222
|
+
now_ms > "${TELEMETRY}/turn_start_ms" 2>/dev/null || true
|
|
223
|
+
;;
|
|
224
|
+
bash-start)
|
|
225
|
+
TK="$(tool_key)" || exit 0
|
|
226
|
+
mkdir -p "${TELEMETRY}/bash_start_ms" "${TELEMETRY}/bash_count" 2>/dev/null || exit 0
|
|
227
|
+
now_ms > "${TELEMETRY}/bash_start_ms/${TK}" 2>/dev/null || true
|
|
228
|
+
: > "${TELEMETRY}/bash_count/${TK}" 2>/dev/null || true
|
|
229
|
+
;;
|
|
126
230
|
edit)
|
|
127
231
|
# Documentation writes do not re-arm the nudge: the closeout skills
|
|
128
232
|
# Closeout skills write docs AFTER the final green verify. Relay's JSON is
|
|
@@ -141,7 +245,20 @@ case "${MODE}" in
|
|
|
141
245
|
;;
|
|
142
246
|
esac
|
|
143
247
|
;;
|
|
144
|
-
bash)
|
|
248
|
+
bash|bash-failure)
|
|
249
|
+
TK="$(tool_key)" || TK=unknown
|
|
250
|
+
mkdir -p "${TELEMETRY}/bash_count" "${TELEMETRY}/bash_intervals" 2>/dev/null || true
|
|
251
|
+
: > "${TELEMETRY}/bash_count/${TK}" 2>/dev/null || true
|
|
252
|
+
START_MS="$(cat "${TELEMETRY}/bash_start_ms/${TK}" 2>/dev/null || true)"
|
|
253
|
+
END_MS="$(now_ms || true)"
|
|
254
|
+
case "${START_MS}:${END_MS}" in
|
|
255
|
+
*[!0-9:]*|:|*:|*::* ) : ;;
|
|
256
|
+
*)
|
|
257
|
+
if [ "${END_MS}" -ge "${START_MS}" ] 2>/dev/null; then
|
|
258
|
+
printf '%s %s\n' "${START_MS}" "${END_MS}" > "${TELEMETRY}/bash_intervals/${TK}" 2>/dev/null || true
|
|
259
|
+
fi
|
|
260
|
+
;;
|
|
261
|
+
esac
|
|
145
262
|
CMD="$(pyfield "d.get('tool_input', {}).get('command')")"
|
|
146
263
|
IS_VERIFY=no
|
|
147
264
|
if [ -n "${CMD}" ]; then
|
|
@@ -155,15 +272,31 @@ case "${MODE}" in
|
|
|
155
272
|
fi
|
|
156
273
|
fi
|
|
157
274
|
if [ "${IS_VERIFY}" = yes ]; then
|
|
158
|
-
|
|
159
|
-
|
|
275
|
+
mkdir -p "${TELEMETRY}/verify_count" 2>/dev/null || true
|
|
276
|
+
: > "${TELEMETRY}/verify_count/${TK}" 2>/dev/null || true
|
|
277
|
+
# Best-effort red/green from the tool response; failure hooks are red.
|
|
278
|
+
if [ "${MODE}" = bash-failure ]; then
|
|
279
|
+
STATUS=fail
|
|
280
|
+
else
|
|
281
|
+
STATUS="$(pyfield "(lambda r, c=None: (lambda c: 'ok' if c == 0 else ('fail' if isinstance(c, int) else ('fail' if r.get('is_error') is True else 'ran')))(r.get('exit_code', r.get('exitCode'))))(d.get('tool_response') or {})")"
|
|
282
|
+
fi
|
|
160
283
|
printf '%s\n' "${STATUS:-ran}" > "${STATE}/last_verify"
|
|
161
|
-
#
|
|
162
|
-
#
|
|
163
|
-
printf '%s
|
|
284
|
+
# Keep only an opaque digest for strict-gate equality; raw commands may
|
|
285
|
+
# contain paths or secrets and must never persist in shared state.
|
|
286
|
+
printf '%s' "${CMD}" | python3 -c 'import hashlib,sys; print(hashlib.sha256(sys.stdin.buffer.read()).hexdigest())' \
|
|
287
|
+
> "${STATE}/last_verify_cmd_hash" 2>/dev/null || true
|
|
164
288
|
rm -f "${STATE}/nudged"
|
|
165
289
|
fi
|
|
166
290
|
;;
|
|
291
|
+
skill|skill-prompt)
|
|
292
|
+
if [ "${MODE}" = skill-prompt ]; then
|
|
293
|
+
EXPANSION_TYPE="$(pyfield "d.get('expansion_type')")"
|
|
294
|
+
[ "${EXPANSION_TYPE}" = slash_command ] || exit 0
|
|
295
|
+
fi
|
|
296
|
+
TK="$(tool_key)" || TK=unknown
|
|
297
|
+
mkdir -p "${TELEMETRY}/skill_count" 2>/dev/null || true
|
|
298
|
+
: > "${TELEMETRY}/skill_count/${TK}" 2>/dev/null || true
|
|
299
|
+
;;
|
|
167
300
|
stop)
|
|
168
301
|
# Never re-block a continuation that a stop hook itself caused
|
|
169
302
|
ACTIVE="$(pyfield "d.get('stop_hook_active')")"
|
|
@@ -178,6 +311,7 @@ case "${MODE}" in
|
|
|
178
311
|
# break both the fail-open and the never-re-block guarantees.
|
|
179
312
|
JSON_OK="$(printf '%s' "${IN}" | python3 -c 'import json,sys; json.load(sys.stdin); print("yes")' 2>/dev/null || echo no)"
|
|
180
313
|
if [ -n "${STRICT_CMD}" ] && [ "${JSON_OK}" = yes ]; then
|
|
314
|
+
STRICT_START_MS="$(now_ms || true)"
|
|
181
315
|
OUT="$(python3 -c '
|
|
182
316
|
import os, subprocess, sys
|
|
183
317
|
state, cwd, cmd, timeout = sys.argv[1:5]
|
|
@@ -192,13 +326,14 @@ def read(name):
|
|
|
192
326
|
except OSError:
|
|
193
327
|
return ""
|
|
194
328
|
e, v = m("last_edit"), m("last_verify")
|
|
195
|
-
# Fast path only for a green
|
|
196
|
-
#
|
|
329
|
+
# Fast path only for a green whose command digest exactly matches. A
|
|
330
|
+
# broad-regex green from a mere read of the test file must
|
|
197
331
|
# not disarm a gate whose promise is "actually runs the command".
|
|
198
|
-
vcmd = read("
|
|
332
|
+
vcmd = read("last_verify_cmd_hash")
|
|
333
|
+
cmd_hash = __import__("hashlib").sha256(cmd.encode()).hexdigest()
|
|
199
334
|
if (v is not None and read("last_verify") == "ok"
|
|
200
335
|
and (e is None or e <= v)
|
|
201
|
-
and
|
|
336
|
+
and vcmd == cmd_hash):
|
|
202
337
|
print("green"); sys.exit(0)
|
|
203
338
|
try:
|
|
204
339
|
r = subprocess.run(cmd, shell=True, cwd=cwd or None, timeout=float(timeout),
|
|
@@ -218,14 +353,18 @@ else:
|
|
|
218
353
|
case "${OUT%%$'\n'*}" in
|
|
219
354
|
green) stat_log stop-clean; exit 0 ;;
|
|
220
355
|
ok)
|
|
356
|
+
record_strict_telemetry "${STRICT_START_MS}"
|
|
221
357
|
printf 'ok\n' > "${STATE}/last_verify" 2>/dev/null || true
|
|
222
|
-
printf '%s
|
|
358
|
+
printf '%s' "${STRICT_CMD}" | python3 -c 'import hashlib,sys; print(hashlib.sha256(sys.stdin.buffer.read()).hexdigest())' \
|
|
359
|
+
> "${STATE}/last_verify_cmd_hash" 2>/dev/null || true
|
|
223
360
|
rm -f "${STATE}/nudged"
|
|
224
361
|
stat_log stop-clean
|
|
225
362
|
exit 0 ;;
|
|
226
363
|
red)
|
|
364
|
+
record_strict_telemetry "${STRICT_START_MS}"
|
|
227
365
|
printf 'fail\n' > "${STATE}/last_verify" 2>/dev/null || true
|
|
228
|
-
printf '%s
|
|
366
|
+
printf '%s' "${STRICT_CMD}" | python3 -c 'import hashlib,sys; print(hashlib.sha256(sys.stdin.buffer.read()).hexdigest())' \
|
|
367
|
+
> "${STATE}/last_verify_cmd_hash" 2>/dev/null || true
|
|
229
368
|
stat_log strict-block
|
|
230
369
|
echo "Strict verify gate: '${STRICT_CMD}' is RED. Fix it before finishing — or say plainly that you are handing back a red state. Failing output:" >&2
|
|
231
370
|
echo "${OUT#red}" >&2
|