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 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), 9 on-demand skills,
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:luciazero-bootstrap`. Plugin skills use the
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 9 skills only: no doctrine, reviewer, or hooks.
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 9 skills
144
+ ## The 11 skills
144
145
 
145
- Run `/luciazero-bootstrap` first; the rest activate when their moment arrives.
146
+ Run `/ready` first; the rest activate when their moment arrives.
146
147
 
147
148
  | Moment | Skill | Result |
148
149
  |---|---|---|
149
- | Entering a repository | `/luciazero-bootstrap` | Finds or creates a verify command and proves it can fail |
150
- | Before risky or multi-step work | `/plan` | Fixes scope and observable acceptance evidence |
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 suite covers scripts, hook state, Relay, bisect, plugin/npm manifests,
221
- self-proving eval graders, and sandboxed install reinstall uninstall for
222
- Claude Code and Codex.
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
- 9 ตัว, hook ติดตามการ verify, reviewer ที่ route ตามความเสี่ยง และ eval harness
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:luciazero-bootstrap` ชื่อ skill แบบ plugin มี prefix
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 9 ตัว ไม่มี doctrine, reviewer หรือ hook
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 ทั้ง 9 ตัว
140
+ ## Skill ทั้ง 11 ตัว
140
141
 
141
- รัน `/luciazero-bootstrap` ก่อนหนึ่งครั้ง ที่เหลือใช้เมื่อถึงจังหวะของมัน
142
+ รัน `/ready` ก่อนหนึ่งครั้ง ที่เหลือใช้เมื่อถึงจังหวะของมัน
142
143
 
143
144
  | จังหวะ | Skill | ผลลัพธ์ |
144
145
  |---|---|---|
145
- | เข้า repository | `/luciazero-bootstrap` | หาหรือสร้างคำสั่ง verify และพิสูจน์ว่าแดงได้ |
146
- | ก่อนงานเสี่ยงหรือหลายขั้น | `/plan` | ล็อก scope และหลักฐานยอมรับที่สังเกตได้ |
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
- ชุดทดสอบครอบคลุม script, state ของ hook, Relay, bisect, manifest ของ plugin/npm,
216
- eval grader ที่พิสูจน์ตัวเองได้ และ install reinstall → uninstall แบบ sandbox
217
- ทั้ง Claude Code และ Codex
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
 
@@ -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()) {
@@ -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
- state = os.path.join(os.environ.get("TMPDIR", "/tmp"), "luciazero-verify-state", key)
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 : record verify runs + status
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 and verify mode, never the project path or
33
- # command. Uninstall keeps it (it is learned data).
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
- STATE="${TMPDIR:-/tmp}/luciazero-verify-state/${KEY}"
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
- # Best-effort red/green from the tool response; unknown shape -> "ran"
159
- 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 {})")"
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
- # remember WHICH command produced the state the strict gate's fast
162
- # path trusts a green only when this matches its own command
163
- printf '%s\n' "${CMD}" > "${STATE}/last_verify_cmd" 2>/dev/null || true
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 that THIS command (or a longer invocation of
196
- # it) produced — a broad-regex green from a mere read of the test file must
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("last_verify_cmd")
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 (vcmd == cmd or vcmd.startswith(cmd + " "))):
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\n' "${STRICT_CMD}" > "${STATE}/last_verify_cmd" 2>/dev/null || true
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\n' "${STRICT_CMD}" > "${STATE}/last_verify_cmd" 2>/dev/null || true
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