avorelo 0.4.0-rc.2 → 0.4.0-rc.5

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "avorelo",
3
- "version": "0.4.0-rc.2",
3
+ "version": "0.4.0-rc.5",
4
4
  "private": false,
5
5
  "avoreloDistributionState": "ready_to_publish",
6
6
  "description": "Avorelo local-first activation, safety, and optimization runtime.",
@@ -33,6 +33,8 @@ const READINESS_CHECKS = Object.freeze([
33
33
  { id: "brain_pack", label: "Brain pack", required: true },
34
34
  { id: "session_context", label: "Session context ready", required: true },
35
35
  { id: "operating_value", label: "Value tracking baseline", required: true },
36
+ { id: "handler_sanity", label: "Handler sanity (structural)", required: true },
37
+ { id: "runtime_sanity", label: "Live runtime verified", required: false },
36
38
  { id: "dashboard_preview", label: "Dashboard ready", required: false },
37
39
  { id: "proof_summary", label: "Proof summary", required: false },
38
40
  { id: "security_baseline", label: "Security checks ready", required: false },
@@ -232,6 +234,28 @@ function checkReadiness(cwd, context) {
232
234
  // security_baseline (non-blocking — intake may not have run yet)
233
235
  checks.security_baseline = safeExists(cwd, ".claude/cco/security");
234
236
 
237
+ // handler_sanity (RC5) — STRUCTURAL behavioral check, required. Runs a benign and a
238
+ // mutating turn through the real handlers and requires the benign turn is NOT
239
+ // self-blocked. This is NOT live-host proof — it cannot show Claude Code loaded the
240
+ // hooks. It only guarantees Avorelo's own handlers won't self-trap a conversation.
241
+ try {
242
+ const { buildHandlerSanityProof } = require("../lifecycle-hooks");
243
+ const proof = buildHandlerSanityProof({});
244
+ checks.handler_sanity = proof.status === "pass";
245
+ } catch {
246
+ checks.handler_sanity = false;
247
+ }
248
+
249
+ // runtime_sanity (RC5) — LIVE-HOST state, NON-blocking. Passed only after a real
250
+ // CLAUDE_CODE_LIVE benign turn; pending at activation time (the customer has not taken
251
+ // a turn yet). Surfaced honestly; never satisfied by simulation/handler/test.
252
+ try {
253
+ const { readRuntimeSanity } = require("../lifecycle-hooks");
254
+ checks.runtime_sanity = readRuntimeSanity(cwd).state === "passed";
255
+ } catch {
256
+ checks.runtime_sanity = false;
257
+ }
258
+
235
259
  // model_routing (from context, non-blocking)
236
260
  checks.model_routing = Boolean(context && context.detectedModelRouting && context.detectedModelRouting.status);
237
261
 
@@ -35,7 +35,7 @@ function defaultActivationState(cwd) {
35
35
  manualFixesRequired: [],
36
36
  nextAction: {
37
37
  label: "Check setup",
38
- command: "npx avorelo@latest activate",
38
+ command: require("../runtime-provenance").versionAwareActivateCommand("activate"),
39
39
  reason: "Activation has not run yet.",
40
40
  blocking: false,
41
41
  },
@@ -39,7 +39,7 @@ function buildContextPack(cwd, context) {
39
39
  commands: {
40
40
  status: "avorelo status --text",
41
41
  audit: "avorelo audit",
42
- activate: "npx avorelo@latest activate",
42
+ activate: require("../runtime-provenance").versionAwareActivateCommand("activate"),
43
43
  },
44
44
  hosts: (context.detectedHosts || [])
45
45
  .filter((host) => host.detected)
@@ -145,7 +145,7 @@ function runRepairEngine(cwd, context, options = {}) {
145
145
  manualFixesRequired.push({
146
146
  id: "run_activation_with_repair",
147
147
  label: "Run activation with repair enabled",
148
- command: "npx avorelo@latest activate --repair",
148
+ command: require("../runtime-provenance").versionAwareActivateCommand("activate --repair"),
149
149
  reason: "Avorelo can recreate missing local state automatically when repair is enabled.",
150
150
  blocking: false,
151
151
  });
@@ -302,7 +302,7 @@ function verifyRunEntry(cwd, options = {}) {
302
302
  checks,
303
303
  missingRequired,
304
304
  advisoryMissing: advisory,
305
- repairCommand: "npx avorelo@latest activate",
305
+ repairCommand: require("../runtime-provenance").versionAwareActivateCommand("activate"),
306
306
  };
307
307
  }
308
308
 
@@ -193,6 +193,9 @@ function getMatchedRules(input = {}, classification, profile) {
193
193
  const unknownTool = reasons.has("UNKNOWN_MCP_TOOL");
194
194
  const writeCapableTool = reasons.has("WRITE_CAPABLE_TOOL");
195
195
  const destructiveCommand = reasons.has("DESTRUCTIVE_COMMAND") || reasons.has("FORCE_PUSH_OR_HISTORY_REWRITE");
196
+ const secretFileRead = reasons.has("SECRET_FILE_READ");
197
+ const broadPermissionChange = reasons.has("BROAD_PERMISSION_CHANGE");
198
+ const writeOutsideProject = reasons.has("WRITE_OUTSIDE_PROJECT");
196
199
  const externalExport = reasons.has("EXTERNAL_REPO_EXPORT");
197
200
  const externalNetwork = reasons.has("EXTERNAL_NETWORK_REQUEST");
198
201
  const localhostVisualQa = actionType === "visual_qa_run" && reasons.has("VISUAL_QA_LOCAL_TARGET");
@@ -309,6 +312,30 @@ function getMatchedRules(input = {}, classification, profile) {
309
312
  });
310
313
  }
311
314
 
315
+ if (secretFileRead) {
316
+ rules.push({
317
+ id: "secret_file_read_block",
318
+ outcome: "block",
319
+ summary: "Reading secret-bearing files (.env, keys, credentials) is blocked.",
320
+ });
321
+ }
322
+
323
+ if (writeOutsideProject) {
324
+ rules.push({
325
+ id: "write_outside_project_block",
326
+ outcome: "block",
327
+ summary: "Writes outside the project root are blocked.",
328
+ });
329
+ }
330
+
331
+ if (broadPermissionChange) {
332
+ rules.push({
333
+ id: "broad_permission_change_guard",
334
+ outcome: "approval_required",
335
+ summary: "Broad or recursive permission changes require explicit approval.",
336
+ });
337
+ }
338
+
312
339
  if (externalExport) {
313
340
  rules.push({
314
341
  id: "external_export_guard",
@@ -0,0 +1,94 @@
1
+ "use strict";
2
+
3
+ // Shared detection for secret-bearing file targets, secret-file reads,
4
+ // and broad permission changes. Used by both the live lifecycle PreToolUse
5
+ // hook and the guard/agent-enforcement CLI path so the two surfaces cannot drift.
6
+
7
+ function normalize(value) {
8
+ return String(value || "").replace(/\\/g, "/").trim();
9
+ }
10
+
11
+ // Secret-bearing file patterns (env files, package/registry auth, ssh/tls keys, secret bundles).
12
+ const SECRET_FILE_PATTERNS = Object.freeze([
13
+ /(^|\/)\.env(\.[A-Za-z0-9._-]+)?$/i,
14
+ /(^|\/)\.npmrc$/i,
15
+ /(^|\/)\.pypirc$/i,
16
+ /(^|\/)\.netrc$/i,
17
+ /(^|\/)id_rsa(\.pub)?$/i,
18
+ /(^|\/)id_ed25519(\.pub)?$/i,
19
+ /(^|\/)id_dsa(\.pub)?$/i,
20
+ /(^|\/)id_ecdsa(\.pub)?$/i,
21
+ /\.pem$/i,
22
+ /\.key$/i,
23
+ /\.p12$/i,
24
+ /\.pfx$/i,
25
+ /(^|\/)secrets?(\.[A-Za-z0-9._-]+)?$/i,
26
+ /(^|\/)credentials?(\.[A-Za-z0-9._-]+)?$/i,
27
+ /(^|\/)\.ssh(\/|$)/i,
28
+ /(^|\/)\.aws(\/|$)/i,
29
+ ]);
30
+
31
+ function isSecretFilePath(target) {
32
+ const value = normalize(target);
33
+ if (!value) return false;
34
+ return SECRET_FILE_PATTERNS.some((pattern) => pattern.test(value));
35
+ }
36
+
37
+ // Shell verbs that read/print file contents (POSIX + PowerShell/Windows).
38
+ const READ_VERB = /\b(cat|type|more|less|head|tail|nl|strings|xxd|od|hexdump|base64|grep|egrep|fgrep|rg|ag|findstr|sort|uniq|awk|sed|Get-Content|gc|Select-String|sls)\b/i;
39
+
40
+ // A token inside a command that looks like a secret file path.
41
+ const SECRET_TOKEN = /(?:^|[\s"'=(])(?:[^\s"'|;&()]*\/)?(?:\.env(?:\.[A-Za-z0-9._-]+)?|\.npmrc|\.pypirc|\.netrc|id_rsa|id_ed25519|id_dsa|id_ecdsa|[^\s"'|;&()]*\.pem|[^\s"'|;&()]*\.key|[^\s"'|;&()]*\.p12|[^\s"'|;&()]*\.pfx|secrets?(?:\.[A-Za-z0-9._-]+)?|credentials?(?:\.[A-Za-z0-9._-]+)?)(?=$|[\s"'|;&)])/i;
42
+
43
+ function detectSecretFileRead(command) {
44
+ const text = String(command || "");
45
+ if (!text.trim()) return null;
46
+ if (!READ_VERB.test(text)) return null;
47
+ if (!SECRET_TOKEN.test(text)) return null;
48
+ return {
49
+ code: "SECRET_FILE_READ",
50
+ message: "Reading secret-bearing files (.env, keys, credentials) is blocked.",
51
+ };
52
+ }
53
+
54
+ // Broad / recursive permission changes: chmod 777, recursive chmod, or broad Windows ACL grants.
55
+ function detectBroadPermissionChange(command) {
56
+ const text = String(command || "");
57
+ if (!text.trim()) return null;
58
+
59
+ const chmod = /\bchmod\b/i.test(text);
60
+ const recursive = /\b(-R|--recursive)\b/i.test(text) || /\bchmod\b[^\n]*\s-\w*R/i.test(text);
61
+ const worldWritable = /\b(777|666|a\+rwx|ugo\+rwx|o\+w|a\+w)\b/i.test(text);
62
+ const broadTarget = /\bchmod\b[^\n]*\s(\.|\/|\*|~|\$HOME)(\s|$)/i.test(text);
63
+
64
+ if (chmod && worldWritable && (recursive || broadTarget)) {
65
+ return {
66
+ code: "BROAD_PERMISSION_CHANGE",
67
+ severity: "high",
68
+ message: "Broad recursive permission change (chmod 777 across the repo) is blocked.",
69
+ };
70
+ }
71
+ if (chmod && worldWritable) {
72
+ return {
73
+ code: "BROAD_PERMISSION_CHANGE",
74
+ severity: "medium",
75
+ message: "World-writable chmod is unsafe. Prefer least privilege.",
76
+ };
77
+ }
78
+ // Windows broad ACL grant to Everyone / full control across a tree.
79
+ if (/\bicacls\b/i.test(text) && /\/grant\b/i.test(text) && /(Everyone|Users):\(?[^)]*F/i.test(text) && /\/t\b/i.test(text)) {
80
+ return {
81
+ code: "BROAD_PERMISSION_CHANGE",
82
+ severity: "high",
83
+ message: "Broad recursive Windows ACL grant is blocked.",
84
+ };
85
+ }
86
+ return null;
87
+ }
88
+
89
+ module.exports = {
90
+ SECRET_FILE_PATTERNS,
91
+ isSecretFilePath,
92
+ detectSecretFileRead,
93
+ detectBroadPermissionChange,
94
+ };
@@ -80,42 +80,68 @@ function readLatestDogfoodSignals(cwd) {
80
80
 
81
81
  // ── Project builder ───────────────────────────────────────────────────────────
82
82
 
83
+ function extractClaudeMdBullets(content, headingRegex) {
84
+ const lines = String(content || "").split("\n");
85
+ const out = [];
86
+ let capturing = false;
87
+ for (const line of lines) {
88
+ if (/^#{1,6}\s/.test(line)) {
89
+ if (capturing) break; // a new heading ends the captured section
90
+ capturing = headingRegex.test(line);
91
+ continue;
92
+ }
93
+ if (capturing) {
94
+ const m = line.match(/^\s*[-*]\s+(.*)$/);
95
+ if (m && m[1].trim()) out.push(m[1].trim().slice(0, 120));
96
+ }
97
+ }
98
+ return out.slice(0, 8);
99
+ }
100
+
101
+ // Derive project facts from real evidence (CLAUDE.md, package.json, work-control).
102
+ // Never inject Avorelo's own roadmap/architecture into an unrelated project.
83
103
  function buildProject(cwd, pkg) {
84
104
  const name = pkg?.name || path.basename(cwd);
85
105
  const productName = pkg?.productName || pkg?.name || name;
86
106
 
87
- // Try to read CLAUDE.md for product direction hints
88
- let productDirection = "Local AI coding optimization platform.";
89
- let architecturePrinciples = [
90
- "local-first, no mandatory cloud",
91
- "deterministic, no LLM calls in core logic",
92
- "compact receipts, not huge JSON",
93
- "capability-router pattern: one router, no duplicates",
94
- "evidence-backed decisions",
95
- ];
96
- let nonGoals = [
97
- "AI Workspace Registry (deferred)",
98
- "Browser Proof (deferred)",
99
- "Workflow Intelligence Radar (deferred)",
100
- "Full model gateway (deferred)",
101
- "Pricing / website (deferred)",
102
- ];
107
+ let productDirection = "";
108
+ let architecturePrinciples = [];
109
+ let nonGoals = [];
110
+ let evidenceBasis = "none";
103
111
 
112
+ let claudeMd = "";
104
113
  try {
105
114
  const claudeMdPath = path.join(cwd, "CLAUDE.md");
106
- if (fs.existsSync(claudeMdPath)) {
107
- const content = fs.readFileSync(claudeMdPath, "utf8");
108
- // Extract first non-empty line after # heading as direction hint
109
- const lines = content.split("\n").filter((l) => l.trim() && !l.startsWith("#")).slice(0, 3);
110
- if (lines.length) productDirection = lines[0].slice(0, 200);
111
- }
115
+ if (fs.existsSync(claudeMdPath)) claudeMd = fs.readFileSync(claudeMdPath, "utf8");
112
116
  } catch {}
113
117
 
114
- // Detect current roadmap stage from work-control or package version
118
+ if (claudeMd) {
119
+ evidenceBasis = "claude_md";
120
+ const lines = claudeMd
121
+ .split("\n")
122
+ .map((l) => l.trim())
123
+ .filter((l) => l && !l.startsWith("#") && !l.startsWith("<!--") && !l.startsWith("-->") && !l.startsWith("<"))
124
+ .slice(0, 5);
125
+ if (lines.length) productDirection = lines[0].slice(0, 200);
126
+ architecturePrinciples = extractClaudeMdBullets(claudeMd, /(architecture|convention|principle)/i);
127
+ nonGoals = extractClaudeMdBullets(claudeMd, /(non[- ]?goal|boundaries|out of scope|do not)/i);
128
+ }
129
+
130
+ if (!productDirection) {
131
+ if (pkg?.description) {
132
+ productDirection = String(pkg.description).slice(0, 200);
133
+ if (evidenceBasis === "none") evidenceBasis = "package_json";
134
+ } else {
135
+ productDirection = "No project-specific direction recorded yet.";
136
+ }
137
+ }
138
+
115
139
  const wcReceipt = readWorkControlForBrainPack(cwd);
116
140
  const currentRoadmapStage = wcReceipt?.task?.text
117
141
  ? String(wcReceipt.task.text).slice(0, 100)
118
- : "Context Intelligence Completion v1";
142
+ : pkg?.version
143
+ ? `v${pkg.version}`
144
+ : "No project-specific stage recorded yet.";
119
145
 
120
146
  return {
121
147
  name: redactIfSecret(name),
@@ -125,6 +151,7 @@ function buildProject(cwd, pkg) {
125
151
  productDirection,
126
152
  nonGoals,
127
153
  architecturePrinciples,
154
+ evidenceBasis,
128
155
  };
129
156
  }
130
157
 
@@ -711,7 +711,7 @@ function buildStatusDashboard(cwd, options = {}) {
711
711
  const scopeRepair = (() => { try { return buildScopeRepairSurface(cwd); } catch { return null; } })();
712
712
  const nextRunContinuity = (() => { try { return buildNextRunContinuitySurface(cwd, { buildIfMissing: false }); } catch { return null; } })();
713
713
  const dataPermissions = (() => { try { return buildDataPermissionsGuardSurface(cwd, { buildIfMissing: false }); } catch { return null; } })();
714
- const deploymentEnvironment = (() => { try { return buildDeploymentEnvironmentGuardSurface(cwd, { buildIfMissing: false }); } catch { return null; } })();
714
+ const deploymentEnvironment = (() => { try { return buildDeploymentEnvironmentGuardSurface(cwd, { buildIfMissing: true }); } catch { return null; } })();
715
715
  const supplyChainDependency = (() => { try { return buildSupplyChainDependencyGuardSurface(cwd, { buildIfMissing: false }); } catch { return null; } })();
716
716
  const observabilityRecovery = (() => { try { return buildObservabilityRecoveryGuardSurface(cwd, { buildIfMissing: false }); } catch { return null; } })();
717
717
  const costAbuse = (() => { try { return buildCostAbuseGuardSurface(cwd, { buildIfMissing: false }); } catch { return null; } })();
@@ -16,15 +16,28 @@ const BACKUP_DIR_REL = `${APPLY_DIR_REL}/backups`;
16
16
  // Avorelo hook marker used to identify managed entries in config
17
17
  const AVORELO_HOOK_MARKER = "_avorelo_managed";
18
18
 
19
- // The lifecycle-hook commands that Avorelo manages
20
- // Uses npx to resolve the binary from the project's node_modules
19
+ // Runtime-provenance policy lives in runtime-provenance.js (single source of truth):
20
+ // prereleases pin an exact package spec so `npx` cannot silently resolve to `latest`.
21
+ const {
22
+ resolveAvoreloVersion,
23
+ isPrereleaseVersion,
24
+ avoreloNpxSpec,
25
+ } = require("./runtime-provenance");
26
+
27
+ function lifecycleHookCommand(hookId, version = resolveAvoreloVersion()) {
28
+ return `npx ${avoreloNpxSpec(version)} lifecycle-hook ${hookId} --json`;
29
+ }
30
+
31
+ // The lifecycle hooks that Avorelo manages. The `command` field is derived
32
+ // version-aware at build time via lifecycleHookCommand(); the static string
33
+ // here is a stable-spec reference used only for shape/metadata.
21
34
  const AVORELO_LIFECYCLE_COMMANDS = Object.freeze([
22
- { event: "SessionStart", command: "npx avorelo lifecycle-hook session-start --json", blocking: false, matcher: "" },
23
- { event: "UserPromptSubmit", command: "npx avorelo lifecycle-hook user-prompt-submit --json", blocking: false, matcher: "" },
24
- { event: "PreToolUse", command: "npx avorelo lifecycle-hook pre-tool-use --json", blocking: true, matcher: "" },
25
- { event: "PostToolUse", command: "npx avorelo lifecycle-hook post-tool-use --json", blocking: false, matcher: "" },
26
- { event: "Stop", command: "npx avorelo lifecycle-hook stop --json", blocking: true, matcher: "" },
27
- { event: "SessionEnd", command: "npx avorelo lifecycle-hook session-end --json", blocking: false, matcher: "" },
35
+ { event: "SessionStart", hookId: "session-start", command: "npx avorelo lifecycle-hook session-start --json", blocking: false, matcher: "" },
36
+ { event: "UserPromptSubmit", hookId: "user-prompt-submit", command: "npx avorelo lifecycle-hook user-prompt-submit --json", blocking: false, matcher: "" },
37
+ { event: "PreToolUse", hookId: "pre-tool-use", command: "npx avorelo lifecycle-hook pre-tool-use --json", blocking: true, matcher: "" },
38
+ { event: "PostToolUse", hookId: "post-tool-use", command: "npx avorelo lifecycle-hook post-tool-use --json", blocking: false, matcher: "" },
39
+ { event: "Stop", hookId: "stop", command: "npx avorelo lifecycle-hook stop --json", blocking: true, matcher: "" },
40
+ { event: "SessionEnd", hookId: "session-end", command: "npx avorelo lifecycle-hook session-end --json", blocking: false, matcher: "" },
28
41
  ]);
29
42
 
30
43
  // Supported host configs
@@ -115,24 +128,70 @@ function parseExistingConfig(configAbsPath, format) {
115
128
  function isAvoreloManagedHook(entry) {
116
129
  if (!entry || typeof entry !== "object") return false;
117
130
  if (entry[AVORELO_HOOK_MARKER] === true) return true;
131
+ const isLifecycleCommand = (value) => /avorelo(@\S+)?\s+lifecycle-hook/i.test(String(value || ""));
118
132
  if (Array.isArray(entry.hooks)) {
119
- return entry.hooks.some((h) => h && typeof h.command === "string" && h.command.includes("avorelo lifecycle-hook"));
133
+ return entry.hooks.some((h) => h && isLifecycleCommand(h.command));
134
+ }
135
+ return isLifecycleCommand(entry.command);
136
+ }
137
+
138
+ // ── User-scope (global) hook blast-radius ─────────────────────────────────────
139
+ // Avorelo installs hooks project-scoped only. This detects Avorelo-managed hooks
140
+ // that ended up in the USER settings (~/.claude/settings.json) — e.g. migration
141
+ // residue or a manual global install — so `doctor` can warn and offer safe removal.
142
+ function userSettingsPath(homeDir) {
143
+ return path.join(homeDir || os.homedir(), ".claude", "settings.json");
144
+ }
145
+
146
+ function detectUserScopeAvoreloHooks(homeDir) {
147
+ const settingsPath = userSettingsPath(homeDir);
148
+ if (!safeExists(settingsPath)) return { present: false, exists: false, avoreloEvents: [], settingsPath };
149
+ const data = safeReadJson(settingsPath);
150
+ if (!data || typeof data.hooks !== "object" || Array.isArray(data.hooks)) {
151
+ return { present: false, exists: true, avoreloEvents: [], settingsPath };
152
+ }
153
+ const avoreloEvents = [];
154
+ for (const [event, entries] of Object.entries(data.hooks)) {
155
+ if (Array.isArray(entries) && entries.some(isAvoreloManagedHook)) avoreloEvents.push(event);
120
156
  }
121
- const cmd = entry.command || "";
122
- return cmd.includes("avorelo lifecycle-hook");
157
+ return { present: avoreloEvents.length > 0, exists: true, avoreloEvents, settingsPath };
158
+ }
159
+
160
+ // Remove ONLY Avorelo-managed hooks from user settings, preserving every other
161
+ // setting (env, permissions, theme, effort, unrelated hooks). Dry-run by default.
162
+ function removeUserScopeAvoreloHooks(homeDir, options = {}) {
163
+ const settingsPath = userSettingsPath(homeDir);
164
+ if (!safeExists(settingsPath)) {
165
+ return { status: "nothing_to_remove", settingsPath, applied: false };
166
+ }
167
+ const data = safeReadJson(settingsPath);
168
+ if (!data) return { status: "invalid_json", settingsPath, applied: false };
169
+ const before = detectUserScopeAvoreloHooks(homeDir).avoreloEvents;
170
+ if (before.length === 0) return { status: "nothing_to_remove", settingsPath, applied: false };
171
+
172
+ const cleaned = removeAvoreloHooks(data, "claude_code_settings");
173
+ if (!options.apply && !options.yes) {
174
+ return { status: "dry_run", settingsPath, wouldRemoveEvents: before, applied: false };
175
+ }
176
+ // Backup then write, preserving all non-Avorelo settings.
177
+ try {
178
+ fs.copyFileSync(settingsPath, `${settingsPath}.avorelo-backup-${makeTimestamp()}`);
179
+ } catch {}
180
+ safeWriteJson(settingsPath, cleaned);
181
+ return { status: "removed", settingsPath, removedEvents: before, applied: true };
123
182
  }
124
183
 
125
184
  /**
126
185
  * Build the Avorelo hook entries for Claude Code settings.json format.
127
186
  * Claude Code v2.1+ expects: {matcher: string, hooks: [{type, command}]}
128
187
  */
129
- function buildClaudeCodeHookEntries() {
188
+ function buildClaudeCodeHookEntries(version = resolveAvoreloVersion()) {
130
189
  return AVORELO_LIFECYCLE_COMMANDS.map((lc) => ({
131
190
  matcher: lc.matcher,
132
191
  hooks: [
133
192
  {
134
193
  type: "command",
135
- command: lc.command,
194
+ command: lifecycleHookCommand(lc.hookId, version),
136
195
  },
137
196
  ],
138
197
  [AVORELO_HOOK_MARKER]: true,
@@ -143,10 +202,10 @@ function buildClaudeCodeHookEntries() {
143
202
  /**
144
203
  * Build the Avorelo hook entries for OpenHands hooks.json format.
145
204
  */
146
- function buildOpenHandsHookEntries() {
205
+ function buildOpenHandsHookEntries(version = resolveAvoreloVersion()) {
147
206
  return AVORELO_LIFECYCLE_COMMANDS.map((lc) => ({
148
207
  event: lc.event,
149
- command: lc.command,
208
+ command: lifecycleHookCommand(lc.hookId, version),
150
209
  blocking: lc.blocking,
151
210
  [AVORELO_HOOK_MARKER]: true,
152
211
  }));
@@ -525,6 +584,15 @@ function applyHookConfig(cwd, plan, options = {}) {
525
584
  redacted: true,
526
585
  };
527
586
 
587
+ // RC5: applying/changing hooks invalidates any prior runtime sanity — the customer
588
+ // must restart Claude Code and take a fresh benign turn before we claim live-verified.
589
+ if (hostsApplied.length > 0) {
590
+ try {
591
+ const { invalidateRuntimeSanity } = require("./lifecycle-hooks");
592
+ invalidateRuntimeSanity(cwd, "hooks_applied");
593
+ } catch {}
594
+ }
595
+
528
596
  safeWriteJson(receiptPath, receipt);
529
597
  writeLedgerEntry(cwd, "hook_apply", receipt);
530
598
  return receipt;
@@ -837,6 +905,39 @@ function runHookDoctor(cwd, options = {}) {
837
905
  checks.push({ id: "user_hooks_preserved", host: hostKey, status: "pass", message: `${userHooks.length} user hook(s) preserved.` });
838
906
  }
839
907
 
908
+ // Check 1b: HANDLER SANITY (RC5) — structural behavioral check ONLY. Proves the
909
+ // benign handler sequence does not self-block and unverified mutation gates. This is
910
+ // NOT proof that Claude Code loaded the hooks or that the host is usable (that is
911
+ // runtime sanity, below).
912
+ try {
913
+ const { buildHandlerSanityProof } = require("./lifecycle-hooks");
914
+ const sanity = buildHandlerSanityProof({});
915
+ if (sanity.status === "pass") {
916
+ checks.push({ id: "handler_sanity", status: "pass", message: "Handler sanity: benign sequence does not self-block; unverified mutation gates with a reason. (Not a live-host verdict.)" });
917
+ } else if (sanity.conversationCompletes === false) {
918
+ checks.push({ id: "handler_sanity", status: "fail", message: `Handler sanity FAIL: a benign handler sequence self-blocks (Stop=${sanity.evidence?.conversationTurn?.decision}). Do not treat activation as ready.` });
919
+ } else {
920
+ checks.push({ id: "handler_sanity", status: "warn", message: `Handler sanity: intervention did not fire cleanly (${sanity.summary}).` });
921
+ }
922
+ } catch (e) {
923
+ checks.push({ id: "handler_sanity", status: "warn", message: `Handler sanity probe skipped: ${e.message}` });
924
+ }
925
+
926
+ // Check 1c: RUNTIME SANITY STATE (RC5) — honest live-host state. Passed only after a
927
+ // real CLAUDE_CODE_LIVE benign turn; pending after activation / hook change until the
928
+ // customer takes a first normal turn in a fresh Claude Code process.
929
+ try {
930
+ const { readRuntimeSanity } = require("./lifecycle-hooks");
931
+ const rs = readRuntimeSanity(cwd);
932
+ if (rs.state === "passed") {
933
+ checks.push({ id: "runtime_sanity", status: "pass", message: `Runtime sanity PASSED from a real live benign turn (${rs.provenance || "live"}).` });
934
+ } else {
935
+ checks.push({ id: "runtime_sanity", status: "warn", message: "Runtime sanity PENDING: restart Claude Code once; it will verify automatically on your first normal turn. (Not yet live-verified — this is honest, not a failure.)" });
936
+ }
937
+ } catch (e) {
938
+ checks.push({ id: "runtime_sanity", status: "warn", message: `Runtime sanity state unavailable: ${e.message}` });
939
+ }
940
+
840
941
  // Check 2: Lifecycle handler commands work
841
942
  try {
842
943
  const { handleLifecycleHook } = require("./lifecycle-hooks");
@@ -896,12 +997,28 @@ function runHookDoctor(cwd, options = {}) {
896
997
  : "No backup found. Apply hooks first to enable rollback.",
897
998
  });
898
999
 
899
- // Check 7: No global config modification
900
- checks.push({
901
- id: "no_global_config_modified",
902
- status: "pass",
903
- message: "Hook apply only modifies repo-local config. Global config untouched.",
904
- });
1000
+ // Check 7: User-scope (global) hook blast-radius. Avorelo installs project-scoped
1001
+ // only; warn if Avorelo-managed hooks are present in user settings (cross-project).
1002
+ try {
1003
+ const userScope = detectUserScopeAvoreloHooks(options.homeDir);
1004
+ if (userScope.present) {
1005
+ checks.push({
1006
+ id: "no_global_hook_blast_radius",
1007
+ status: "warn",
1008
+ message: `Avorelo-managed hooks found in USER settings (${userScope.settingsPath}) for: ${userScope.avoreloEvents.join(", ")}. These affect every repository. Avorelo installs project-scoped only — run \`avorelo hooks uninstall --scope user --yes\` to remove just the Avorelo entries (other settings preserved).`,
1009
+ scope: "user",
1010
+ avoreloEvents: userScope.avoreloEvents,
1011
+ });
1012
+ } else {
1013
+ checks.push({
1014
+ id: "no_global_hook_blast_radius",
1015
+ status: "pass",
1016
+ message: "No Avorelo hooks in user/global settings. Hook install is project-scoped only.",
1017
+ });
1018
+ }
1019
+ } catch {
1020
+ checks.push({ id: "no_global_hook_blast_radius", status: "pass", message: "User-scope hook check skipped (no user settings)." });
1021
+ }
905
1022
 
906
1023
  const failCount = checks.filter((c) => c.status === "fail").length;
907
1024
  const warnCount = checks.filter((c) => c.status === "warn").length;
@@ -1056,6 +1173,8 @@ module.exports = {
1056
1173
  validateAppliedHooks,
1057
1174
  rollbackHookApply,
1058
1175
  uninstallAvoreloHooks,
1176
+ detectUserScopeAvoreloHooks,
1177
+ removeUserScopeAvoreloHooks,
1059
1178
  runHookDoctor,
1060
1179
  writeHookApplyReceipt,
1061
1180
  buildHookApplySurface,
@@ -1064,6 +1183,8 @@ module.exports = {
1064
1183
  formatHookDoctorText,
1065
1184
  // Helpers (exported for testing)
1066
1185
  isAvoreloManagedHook,
1186
+ buildClaudeCodeHookEntries,
1187
+ buildOpenHandsHookEntries,
1067
1188
  mergeClaudeCodeConfig,
1068
1189
  mergeOpenHandsConfig,
1069
1190
  removeAvoreloHooks,
@@ -1072,4 +1193,8 @@ module.exports = {
1072
1193
  parseExistingConfig,
1073
1194
  requiresExplicitApproval,
1074
1195
  isDryRun,
1196
+ resolveAvoreloVersion,
1197
+ isPrereleaseVersion,
1198
+ avoreloNpxSpec,
1199
+ lifecycleHookCommand,
1075
1200
  };