@hasna/hooks 0.10.6 → 0.10.8

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/README.md CHANGED
@@ -92,6 +92,49 @@ Repair unsafe installation permissions with a verified package installer, then
92
92
  check again. This check alone does not prove hosted authentication, native hook
93
93
  trust or a running agent's interception.
94
94
 
95
+ ## Planned safety registration
96
+
97
+ For repeatable global setup, use the owning installer through its plan/apply
98
+ commands. Planning leaves native settings unchanged and returns a compact digest.
99
+
100
+ ```bash
101
+ hooks safety registration-plan --target codex
102
+ hooks safety registration-apply --target codex --expected-plan-digest PLAN_SHA256
103
+ # The same two commands support --target claude.
104
+ ```
105
+
106
+ Apply checks the exact predecessor, retains an owner-only original and intent,
107
+ and reads back the result. An identical registration returns unchanged without
108
+ rewriting its settings. The planner honors `CODEX_HOME` and `CLAUDE_CONFIG_DIR`;
109
+ conflicting Hooks path overrides refuse. Missing native settings directories are
110
+ created only on apply. Disabled or asynchronous guards, unsafe paths, duplicate
111
+ JSON keys and numbers that cannot be preserved exactly refuse before a write.
112
+ Managed default Claude settings continue through Skills' coordinated writer.
113
+
114
+ The SDK exports `planNativeSafetyRegistration` and
115
+ `applyNativeSafetyRegistration`. Both return `nativeAdoptionVerified: false`.
116
+ A changed or uncertain apply requires a fresh read-only plan and inspection of
117
+ its recorded operation; no automatic retry or direct overwrite is performed.
118
+
119
+ After registration, Codex trust uses its supported native configuration writer:
120
+
121
+ ```bash
122
+ hooks safety trust-plan --target codex --cwd /path/to/project --codex-command /absolute/path/to/native/codex
123
+ hooks safety trust-apply --target codex --cwd /path/to/project --codex-command /absolute/path/to/native/codex --expected-plan-digest PLAN_SHA256
124
+ ```
125
+
126
+ The exact definition is verified before trusting and enabling only that guard.
127
+ Unrelated configuration is preserved, and an already trusted, enabled guard
128
+ does not cause a write. A disabled native hooks feature is a refusal, not an
129
+ implicit feature change. Neither registration nor trust proves that an existing
130
+ agent has adopted the change.
131
+
132
+ For Claude, `hooks safety verify --target claude --cwd /path/to/project` checks
133
+ the selected settings and bundled supervisor. Its result explicitly leaves
134
+ runtime-policy verification, native discovery and native adoption false;
135
+ MDM/server policy, workspace trust and launch flags require native acceptance.
136
+
137
+
95
138
  ## Codex safety readiness
96
139
 
97
140
  ```bash
@@ -350,8 +393,46 @@ commands are credential-free and return compact JSON. Verification explicitly
350
393
  reports `nativeAdoptionVerified: false`: a fresh native Sumi tool invocation is
351
394
  still required to prove that the installed harness uses this binding.
352
395
 
396
+ The binding is provisioned from the package's own install, upgrade and safety
397
+ surfaces, so a rollout cannot leave Sumi's fail-closed check unbound:
398
+
399
+ - the package lifecycle script (`postinstall`) provisions it for a packaged
400
+ install — a copy inside a `node_modules` directory, never a source checkout;
401
+ - `hooks install` and `hooks upgrade` apply the same step after a successful
402
+ hook install or package upgrade, and report it as `sumiBinding` in `--json`;
403
+ - `hooks safety provision` runs it on demand and is credential-free.
404
+
405
+ Every surface is idempotent and shares one policy: a missing binding is created,
406
+ a byte-identical one is written nowhere, a different binding that still verifies
407
+ is left intact, and one whose pinned runtime or worker no longer verifies (an
408
+ upgrade rewrote the worker, or the runtime prefix was pruned) is re-registered
409
+ with the previous record preserved beside it as `<path>.before-<sha256>`. An
410
+ unreadable or unrecognized record is refused rather than overwritten, and
411
+ provisioning never fails the calling command — it reports a stable `code`,
412
+ because an unavailable Trash guard must not break a hook install. bun blocks
413
+ lifecycle scripts for untrusted dependencies and `--ignore-scripts` skips them,
414
+ so the CLI surfaces carry the same policy wherever the lifecycle script does not
415
+ run. A harness rollout that installs this package with scripts disabled must run
416
+ one of those surfaces; that is the harness owner's half of this contract.
417
+
418
+ The binding pins the absolute paths and sha256 of the Bun runtime and the worker
419
+ bundle as they exist when it is registered, and every evaluation re-verifies
420
+ those exact bytes. A worker inside a tree a package upgrade rewrites in place —
421
+ bun's global install root, its cache, or an isolated `node_modules/.bun/…` store —
422
+ is therefore only valid until that tree is upgraded; the next rewrite refuses
423
+ every command until provisioning runs again. Provisioning still closes the
424
+ immediate gap (an unbound station has no shell at all) but reports it: every
425
+ result carries `durable`, and a false value prints the repair step. Provision
426
+ rollouts from a stable, versioned install tree (for example the fleet's
427
+ `~/.hasna/hooks/runtime/<version>-copyfile` layout) and re-run
428
+ `hooks safety provision` after upgrading a tree the binding points at.
429
+
353
430
  The owner-only `~/.hasna/hooks/native/sumi-trash-guard.json` pins the supervisor's
354
- runtime, worker, home and PATH. Identical setup is a no-op. An upgrade requires
431
+ runtime, worker, home and PATH. Owner and mode are part of the trust decision: a
432
+ record that is not owned by the current user, or that group or others can read or
433
+ write, is refused with the failing property named — never trusted, adopted or
434
+ silently replaced — because a rewritable binding can point the supervisor at a
435
+ different worker. Identical setup is a no-op. An upgrade requires
355
436
  `--expected-binding-sha256 <current-digest>` and preserves the previous record.
356
437
  An interrupted lock or unexpected current record requires reconciliation; setup
357
438
  does not overwrite it. This file is native configuration, not a Hooks app store.
package/bin/hooks-mcp.js CHANGED
@@ -3104,7 +3104,7 @@ function checkScriptHash(name, actual) {
3104
3104
  // src/lib/codex-settings.ts
3105
3105
  import { closeSync as closeSync2, existsSync as existsSync8, lstatSync as lstatSync2, mkdirSync as mkdirSync4, openSync as openSync2, readFileSync as readFileSync5, renameSync as renameSync2, unlinkSync, writeFileSync as writeFileSync3 } from "fs";
3106
3106
  import { dirname as dirname4 } from "path";
3107
- import { randomUUID } from "crypto";
3107
+ import { createHash as createHash3, randomUUID } from "crypto";
3108
3108
  var snapshots = new WeakMap;
3109
3109
  function current(path) {
3110
3110
  try {
@@ -3139,11 +3139,24 @@ function readCodexSettings(path) {
3139
3139
  snapshots.set(settings, { path, raw });
3140
3140
  return settings;
3141
3141
  }
3142
- function writeCodexSettings(path, settings) {
3142
+ function assertSettingsPreimage(raw, expected) {
3143
+ if (expected === undefined)
3144
+ return;
3145
+ if (!/^(?:absent|[a-f0-9]{64})$/.test(expected) || (raw === null ? "absent" : createHash3("sha256").update(raw).digest("hex")) !== expected) {
3146
+ throw new Error("Hook settings changed since planning; no settings were written.");
3147
+ }
3148
+ }
3149
+ function writeCodexSettings(path, settings, expectedSha256) {
3143
3150
  const snapshot = snapshots.get(settings);
3144
3151
  if (!snapshot || snapshot.path !== path)
3145
3152
  throw new Error("Codex settings require a complete current read before writing.");
3146
- mkdirSync4(dirname4(path), { recursive: true });
3153
+ assertSettingsPreimage(snapshot.raw, expectedSha256);
3154
+ writeHookSettingsPreimage(path, settings, snapshot.raw);
3155
+ snapshots.set(settings, { path, raw: JSON.stringify(settings, null, 2) + `
3156
+ ` });
3157
+ }
3158
+ function writeHookSettingsPreimage(path, settings, before) {
3159
+ mkdirSync4(dirname4(path), { recursive: true, mode: 448 });
3147
3160
  const lock = `${path}.hooks-write.lock`;
3148
3161
  let fd;
3149
3162
  try {
@@ -3153,15 +3166,14 @@ function writeCodexSettings(path, settings) {
3153
3166
  }
3154
3167
  const temp = `${path}.${randomUUID()}.tmp`;
3155
3168
  try {
3156
- if (current(path) !== snapshot.raw)
3157
- throw new Error("Codex settings changed since reading; re-read before retrying.");
3169
+ if (current(path) !== before)
3170
+ throw new Error("Hook settings changed since reading; re-read before retrying.");
3158
3171
  const next = JSON.stringify(settings, null, 2) + `
3159
3172
  `;
3160
3173
  writeFileSync3(temp, next, { flag: "wx", mode: 384 });
3161
- if (current(path) !== snapshot.raw)
3162
- throw new Error("Codex settings changed since reading; re-read before retrying.");
3174
+ if (current(path) !== before)
3175
+ throw new Error("Hook settings changed since reading; re-read before retrying.");
3163
3176
  renameSync2(temp, path);
3164
- snapshots.set(settings, { path, raw: next });
3165
3177
  } finally {
3166
3178
  if (existsSync8(temp))
3167
3179
  unlinkSync(temp);
@@ -3173,19 +3185,20 @@ function writeCodexSettings(path, settings) {
3173
3185
  // src/lib/claude-skills-coordination.ts
3174
3186
  import { createHash as createHash5 } from "crypto";
3175
3187
 
3176
- // ../../node_modules/.bun/@hasna+skills@0.9.14+a00d3064559565bb/node_modules/@hasna/skills/dist/index.js
3188
+ // ../../node_modules/.bun/@hasna+skills@0.9.17+a00d3064559565bb/node_modules/@hasna/skills/dist/index.js
3177
3189
  import { existsSync as existsSync9 } from "fs";
3178
3190
  import { homedir as homedir4 } from "os";
3179
3191
  import { join as join8, resolve as resolve3 } from "path";
3180
3192
  import { homedir as pathsResolverHomedir2 } from "os";
3181
3193
  import { join as pathsResolverJoin2 } from "path";
3182
- import { createHash as createHash3 } from "crypto";
3194
+ import { createHash as createHash4 } from "crypto";
3183
3195
  import { existsSync as existsSync52, readdirSync as readdirSync3, readFileSync as readFileSync42, statSync as statSync3 } from "fs";
3184
3196
  import { join as join52, sep as sep2 } from "path";
3185
3197
  import { basename as basename2, isAbsolute as isAbsolute22, resolve as resolve22 } from "path";
3186
3198
  import { createHash as createHash16 } from "crypto";
3187
3199
  import { lstatSync as lstatSync20, readFileSync as readFileSync16, readdirSync as readdirSync9, realpathSync as realpathSync4 } from "fs";
3188
3200
  import { basename as basename8, dirname as dirname16, isAbsolute as isAbsolute14, join as join26, resolve as resolve14, sep as sep5 } from "path";
3201
+ import { isDeepStrictEqual as isDeepStrictEqual3 } from "util";
3189
3202
  import { dirname as dirname32, isAbsolute as isAbsolute3, join as join72, resolve as resolve32 } from "path";
3190
3203
  import { lstatSync as lstatSync3, readFileSync as readFileSync6, opendirSync } from "fs";
3191
3204
  import { createHash as createHash22 } from "crypto";
@@ -3194,7 +3207,7 @@ import { dirname as dirname42, isAbsolute as isAbsolute4, join as join82, resolv
3194
3207
  import { createHash as createHash32 } from "crypto";
3195
3208
  import { closeSync as closeSync3, constants as constants2, fstatSync as fstatSync2, lstatSync as lstatSync5, openSync as openSync3, readSync } from "fs";
3196
3209
  import { dirname as dirname5, isAbsolute as isAbsolute5, resolve as resolve5 } from "path";
3197
- import { createHash as createHash4 } from "crypto";
3210
+ import { createHash as createHash42 } from "crypto";
3198
3211
  import { lstatSync as lstatSync6, readlinkSync } from "fs";
3199
3212
  import { dirname as dirname6, isAbsolute as isAbsolute6, join as join9, parse, resolve as resolve6, sep as sep22 } from "path";
3200
3213
  import { closeSync as closeSync12, constants as constants9, fstatSync as fstatSync12, lstatSync as lstatSync17, openSync as openSync12, opendirSync as opendirSync5, readSync as readSync8 } from "fs";
@@ -10322,7 +10335,7 @@ function* bundleHashParts(files) {
10322
10335
  yield new TextEncoder().encode("\x00");
10323
10336
  }
10324
10337
  function hashBundleFiles(files) {
10325
- const hash = createHash3(CONTENT_HASH_ALGORITHM);
10338
+ const hash = createHash4(CONTENT_HASH_ALGORITHM);
10326
10339
  for (const part of bundleHashParts(files))
10327
10340
  hash.update(part);
10328
10341
  return hash.digest("hex");
@@ -10666,8 +10679,8 @@ function assertAgentPolicyCollections(policy) {
10666
10679
  text(source.path);
10667
10680
  requireBound(source.sha256 === null || typeof source.sha256 === "string" && /^[a-f0-9]{64}$/.test(source.sha256));
10668
10681
  if (source.hashMode !== undefined)
10669
- requireBound(["bytes", "path-bytes", "claude-plugin-registry", "claude-marketplace-registry", "claude-settings-v1", "claude-settings-v2", "claude-marketplace-registry-v2"].includes(source.hashMode) && source.format === undefined && source.fields === undefined && (source.hashMode === "bytes" || source.sha256 !== null));
10670
- if (source.hashMode === "claude-settings-v1" || source.hashMode === "claude-settings-v2")
10682
+ requireBound(["bytes", "path-bytes", "claude-plugin-registry", "claude-marketplace-registry", "claude-settings-v1", "claude-settings-v2", "claude-settings-v3", "claude-marketplace-registry-v2"].includes(source.hashMode) && source.format === undefined && source.fields === undefined && (source.hashMode === "bytes" || source.sha256 !== null));
10683
+ if (source.hashMode === "claude-settings-v1" || source.hashMode === "claude-settings-v2" || source.hashMode === "claude-settings-v3")
10671
10684
  requireBound(agent === "claude" && value.agent === "claude" && value.method === "reviewed" && !/[\x00-\x1f\x7f]/.test(source.path) && isAbsolute22(source.path) && resolve22(source.path) === source.path && basename2(source.path) === "settings.json");
10672
10685
  if (source.hashMode === "claude-marketplace-registry" || source.hashMode === "claude-marketplace-registry-v2")
10673
10686
  requireBound(agent === "claude" && value.agent === "claude" && value.method === "reviewed" && !/[\x00-\x1f\x7f]/.test(source.path) && isAbsolute22(source.path) && resolve22(source.path) === source.path && basename2(source.path) === "known_marketplaces.json");
@@ -11158,11 +11171,11 @@ function hashDiscoveryPathFile(path, budget, changes) {
11158
11171
  const after = trace(path);
11159
11172
  if (JSON.stringify(before.rows) !== JSON.stringify(after.rows) || before.file !== after.file)
11160
11173
  throw new Error("Discovery path input changed during read");
11161
- return createHash4("sha256").update(JSON.stringify({ version: 1, path, rows: before.rows, content })).digest("hex");
11174
+ return createHash42("sha256").update(JSON.stringify({ version: 1, path, rows: before.rows, content })).digest("hex");
11162
11175
  }
11163
11176
  var package_default = {
11164
11177
  name: "@hasna/skills",
11165
- version: "0.9.14",
11178
+ version: "0.9.17",
11166
11179
  description: "Skills library for AI coding agents",
11167
11180
  type: "module",
11168
11181
  bin: {
@@ -12017,7 +12030,7 @@ function assertClaudeHookEventsReplacement(before, replacement, events) {
12017
12030
  need2(JSON.stringify(withoutOwnedEvents(original)) === JSON.stringify(withoutOwnedEvents(next)), `${label} update changed settings outside hooks.${label}`);
12018
12031
  }
12019
12032
  var PERSISTED_EFFORT_LEVELS = new Set(["low", "medium", "high", "xhigh"]);
12020
- function inferencePreference(key, child) {
12033
+ function inferencePreference(key, child, version) {
12021
12034
  if (key === "skipDangerousModePermissionPrompt") {
12022
12035
  need2(child.kind === "literal" && (child.value === "true" || child.value === "false"), "contains an invalid dialog acknowledgment");
12023
12036
  return false;
@@ -12030,7 +12043,7 @@ function inferencePreference(key, child) {
12030
12043
  return false;
12031
12044
  need2(child.kind === "object", "requires an object of model preferences");
12032
12045
  child.entries = child.entries.filter(([model, preferences]) => {
12033
- if (!BUILTIN_MODEL_SELECTIONS.has(model) || preferences.kind !== "object" || preferences.entries.length !== 1 || preferences.entries[0][0] !== "effortLevel")
12046
+ if (version === 2 && !BUILTIN_MODEL_SELECTIONS.has(model) || preferences.kind !== "object" || preferences.entries.length !== 1 || preferences.entries[0][0] !== "effortLevel")
12034
12047
  return true;
12035
12048
  const effort = preferences.entries[0][1];
12036
12049
  need2(effort.kind === "string" && PERSISTED_EFFORT_LEVELS.has(effort.value), "contains an invalid persisted model effort level");
@@ -12041,7 +12054,7 @@ function inferencePreference(key, child) {
12041
12054
  function settingsDigest(text2, version = 1) {
12042
12055
  const value = parse3(text2);
12043
12056
  value.entries = value.entries.filter(([key, child]) => {
12044
- if (version === 2 && inferencePreference(key, child))
12057
+ if (version !== 1 && inferencePreference(key, child, version))
12045
12058
  return false;
12046
12059
  if (key === "model") {
12047
12060
  need2(child.kind === "string", "contains an invalid model selection");
@@ -12079,6 +12092,16 @@ function hashClaudeSettingsReplacementV2(text2, budget) {
12079
12092
  budget.remaining -= bytes;
12080
12093
  return settingsDigest(text2, 2);
12081
12094
  }
12095
+ function captureClaudeSettingsV3(path, budget = { remaining: 256 * 1024 * 1024 }) {
12096
+ return { path, hashMode: "claude-settings-v3", sha256: settingsDigest(readSettings(path, budget), 3) };
12097
+ }
12098
+ function hashClaudeSettingsReplacementV3(text2, budget) {
12099
+ need2(typeof text2 === "string", "requires settings text");
12100
+ const bytes = Buffer.byteLength(text2);
12101
+ need2(bytes <= CLAUDE_SETTINGS_WITNESS_LIMITS.bytes && Number.isSafeInteger(budget.remaining) && budget.remaining >= bytes, "exceeds its byte limit");
12102
+ budget.remaining -= bytes;
12103
+ return settingsDigest(text2, 3);
12104
+ }
12082
12105
  var sha = (data) => createHash10("sha256").update(data).digest("hex");
12083
12106
  var ROOTS = NATIVE_SKILL_ROOTS;
12084
12107
  function assertSafePath(path) {
@@ -12532,6 +12555,8 @@ function applyAgentIntegration(plan) {
12532
12555
  parseManagedSkillPolicy(change.after);
12533
12556
  const aliases = plan.rootAliases ?? [];
12534
12557
  recheckRootAliases(aliases);
12558
+ for (const agent of plan.retainedReviewChecks?.agents ?? [])
12559
+ assertManagedAgentBridge(agent, { ...plan.retainedReviewChecks, dataDir: plan.dataDir });
12535
12560
  for (const agent of plan.managedAgentChecks?.agents ?? [])
12536
12561
  assertManagedAgentBridge(agent, { home: plan.managedAgentChecks.home, dataDir: plan.dataDir, projectDir: plan.managedAgentChecks.home });
12537
12562
  for (const binding of plan.discoveryBefore ?? []) {
@@ -13545,13 +13570,17 @@ function unchanged2(before) {
13545
13570
  const after = snapshot(before.file, before.privateFile, before.readOnlyPackage ? { readOnlyPackage: true } : {});
13546
13571
  need4(sameStat(before.stat, after.stat) && after.sha256 === before.sha256, "INPUT_CHANGED");
13547
13572
  }
13548
- var sha2 = (text2) => createHash15("sha256").update(text2).digest("hex");
13573
+ var events = { UserPromptSubmit: ["userPromptSubmit", "user_prompt_submit"], SessionStart: ["sessionStart", "session_start"], SubagentStart: ["subagentStart", "subagent_start"] };
13574
+ var matchedEvents = { ...events, PreToolUse: ["preToolUse", "pre_tool_use"] };
13549
13575
  var proofs = new WeakMap;
13576
+ var sha2 = (text2) => createHash15("sha256").update(text2).digest("hex");
13577
+ var proofs2 = new WeakMap;
13550
13578
  function assertCodexHookDiscoveryRecovery(binding, witness) {
13551
- const proof = proofs.get(witness);
13579
+ const proof = proofs2.get(witness);
13552
13580
  need4(!!proof && binding.agent === "codex" && witness.discoverySha256 === sha2(JSON.stringify(binding)), "RECONCILE_DISCOVERY_RECOVERY_CHANGED");
13553
13581
  unchanged2(proof.before);
13554
13582
  unchanged2(proof.current);
13583
+ proof.recheckAdditions();
13555
13584
  }
13556
13585
  function verifiesCodexHookDiscoverySource(binding, source, currentDigest, witness) {
13557
13586
  if (!witness || source.path !== witness.configPath || source.hashMode !== "bytes")
@@ -13593,13 +13622,13 @@ function read2(path, changes) {
13593
13622
  return readFileSync16(path, "utf8");
13594
13623
  }
13595
13624
  function projected(source, changes, budget = discoveryByteBudget()) {
13596
- if (source.hashMode === "claude-settings-v1" || source.hashMode === "claude-settings-v2") {
13625
+ if (source.hashMode === "claude-settings-v1" || source.hashMode === "claude-settings-v2" || source.hashMode === "claude-settings-v3") {
13597
13626
  if (source.format !== undefined || source.fields !== undefined || source.managedPlugins !== undefined || typeof source.sha256 !== "string" || !/^[a-f0-9]{64}$/.test(source.sha256))
13598
13627
  throw new Error("Claude settings witnesses require exact typed metadata");
13599
- const current2 = (source.hashMode === "claude-settings-v2" ? captureClaudeSettingsV2 : captureClaudeSettings)(source.path, budget).sha256;
13628
+ const current2 = (source.hashMode === "claude-settings-v3" ? captureClaudeSettingsV3 : source.hashMode === "claude-settings-v2" ? captureClaudeSettingsV2 : captureClaudeSettings)(source.path, budget).sha256;
13600
13629
  if (current2 !== source.sha256)
13601
13630
  throw new Error(`Native discovery input changed; run skills hook install with a fresh discovery review: ${source.path}`);
13602
- return changes?.has(source.path) ? (source.hashMode === "claude-settings-v2" ? hashClaudeSettingsReplacementV2 : hashClaudeSettingsReplacement)(changes.get(source.path), budget) : current2;
13631
+ return changes?.has(source.path) ? (source.hashMode === "claude-settings-v3" ? hashClaudeSettingsReplacementV3 : source.hashMode === "claude-settings-v2" ? hashClaudeSettingsReplacementV2 : hashClaudeSettingsReplacement)(changes.get(source.path), budget) : current2;
13603
13632
  }
13604
13633
  if (source.hashMode === "claude-marketplace-registry" || source.hashMode === "claude-marketplace-registry-v2") {
13605
13634
  if (source.format !== undefined || source.fields !== undefined || source.managedPlugins !== undefined || typeof source.sha256 !== "string" || !/^[a-f0-9]{64}$/.test(source.sha256) || changes?.has(source.path))
@@ -13661,7 +13690,7 @@ function verifyAgentDiscovery(binding, codexRecovery) {
13661
13690
  safe4(root2);
13662
13691
  }
13663
13692
  function assertMarketplaceBinding(binding, source) {
13664
- if ((source.hashMode === "claude-settings-v1" || source.hashMode === "claude-settings-v2") && (binding.agent !== "claude" || binding.method !== "reviewed" || basename8(source.path) !== "settings.json"))
13693
+ if ((source.hashMode === "claude-settings-v1" || source.hashMode === "claude-settings-v2" || source.hashMode === "claude-settings-v3") && (binding.agent !== "claude" || binding.method !== "reviewed" || basename8(source.path) !== "settings.json"))
13665
13694
  throw new Error("Claude settings witnesses require explicit reviewed Claude configuration");
13666
13695
  if ((source.hashMode === "claude-marketplace-registry" || source.hashMode === "claude-marketplace-registry-v2") && (binding.agent !== "claude" || binding.method !== "reviewed"))
13667
13696
  throw new Error("Claude marketplace witnesses require explicit reviewed Claude discovery");
@@ -13670,7 +13699,10 @@ function rebindAgentDiscovery(binding, changes) {
13670
13699
  const budget = discoveryByteBudget();
13671
13700
  return { ...binding, sources: binding.sources.map((source) => {
13672
13701
  assertMarketplaceBinding(binding, source);
13673
- return { ...source, sha256: projected(source, changes, budget) };
13702
+ const sha2562 = projected(source, changes, budget);
13703
+ if (!changes.has(source.path) && sha2562 !== source.sha256)
13704
+ throw new Error(`Native discovery input changed; run skills hook install with a fresh discovery review: ${source.path}`);
13705
+ return { ...source, sha256: sha2562 };
13674
13706
  }) };
13675
13707
  }
13676
13708
  function assertProjectDiscovery(agent, directories, home, canonical4 = resolve14, reviewed) {
@@ -13782,6 +13814,19 @@ function resolveAgentDiscovery(options) {
13782
13814
  }
13783
13815
  }
13784
13816
  }
13817
+ if (options.reviewed === undefined && options.retainedReview !== undefined) {
13818
+ const retained = options.retainedReview;
13819
+ if (retained.agent !== agent || retained.method !== "reviewed")
13820
+ unresolved("invalid retained review");
13821
+ verifyAgentDiscovery(retained);
13822
+ if (!retained.sources.some((source) => source.path === canonical4(configPath) && source.format === undefined && source.fields === undefined))
13823
+ unresolved("retained review is missing the full agent configuration source");
13824
+ if (retained.sources.some((source) => (source.hashMode === "claude-settings-v1" || source.hashMode === "claude-settings-v2" || source.hashMode === "claude-settings-v3") && source.path !== canonical4(configPath)))
13825
+ unresolved("retained settings witness names another configuration source");
13826
+ if (sources.some((source) => !retained.sources.some((saved) => isDeepStrictEqual3(saved, source))) || [...roots].some((root2) => !retained.roots.includes(root2)) || agent === "gemini" && JSON.stringify(retained.builtinNames) !== JSON.stringify(builtinNames))
13827
+ unresolved("retained review is missing current runtime discovery coverage");
13828
+ return retained;
13829
+ }
13785
13830
  if (options.reviewed && (options.reviewed.version !== 1 || !Array.isArray(options.reviewed.agents) || options.reviewed.agents.some((item) => !item || typeof item !== "object") || new Set(options.reviewed.agents.map((item) => item.agent)).size !== options.reviewed.agents.length))
13786
13831
  throw new Error("Invalid --discovery-inputs version or agents");
13787
13832
  const review = options.reviewed?.agents.find((item) => item.agent === agent);
@@ -13792,7 +13837,7 @@ function resolveAgentDiscovery(options) {
13792
13837
  if (review.sources.some((source) => source.format !== undefined || source.fields !== undefined))
13793
13838
  throw new Error("Explicit discovery reviews require full source-file hashes");
13794
13839
  verifyAgentDiscovery(supplied);
13795
- if (review.sources.some((source) => (source.hashMode === "claude-settings-v1" || source.hashMode === "claude-settings-v2") && source.path !== canonical4(configPath)))
13840
+ if (review.sources.some((source) => (source.hashMode === "claude-settings-v1" || source.hashMode === "claude-settings-v2" || source.hashMode === "claude-settings-v3") && source.path !== canonical4(configPath)))
13796
13841
  throw new Error("Semantic settings witnesses must name the configured Claude configuration source");
13797
13842
  if (!review.sources.some((source) => source.path === canonical4(configPath)))
13798
13843
  throw new Error("Discovery review must include the agent configuration source");
@@ -34302,8 +34347,8 @@ var SESSION_RECONCILIATION_PLAN_TTL_MS = 5 * 60 * 1000;
34302
34347
  // src/lib/claude-skills-coordination.ts
34303
34348
  function coordinateClaudeSettings(before, replacement, options) {
34304
34349
  const prior = JSON.parse(before ?? "{}"), next = JSON.parse(replacement);
34305
- const events = new Set([...Object.keys(prior.hooks ?? {}), ...Object.keys(next.hooks ?? {})]);
34306
- const changed = [...events].filter((event) => JSON.stringify(prior.hooks?.[event]) !== JSON.stringify(next.hooks?.[event]));
34350
+ const events2 = new Set([...Object.keys(prior.hooks ?? {}), ...Object.keys(next.hooks ?? {})]);
34351
+ const changed = [...events2].filter((event) => JSON.stringify(prior.hooks?.[event]) !== JSON.stringify(next.hooks?.[event]));
34307
34352
  const selected = changed.length ? changed : ["PreToolUse"];
34308
34353
  const plan = planClaudeHookEventsUpdate({
34309
34354
  ...options,
@@ -34389,9 +34434,9 @@ function getTargetSettingsDir(target) {
34389
34434
  }
34390
34435
  function getGlobalSettingsPathOverride(target) {
34391
34436
  if (target === "codex")
34392
- return process.env.HASNA_HOOKS_CODEX_SETTINGS_PATH;
34437
+ return process.env.HASNA_HOOKS_CODEX_SETTINGS_PATH ?? (process.env.CODEX_HOME ? resolve7(process.env.CODEX_HOME, "hooks.json") : undefined);
34393
34438
  if (target === "claude")
34394
- return process.env.HASNA_HOOKS_CLAUDE_SETTINGS_PATH;
34439
+ return process.env.HASNA_HOOKS_CLAUDE_SETTINGS_PATH ?? (process.env.CLAUDE_CONFIG_DIR ? resolve7(process.env.CLAUDE_CONFIG_DIR, "settings.json") : undefined);
34395
34440
  if (target === "gemini")
34396
34441
  return process.env.HASNA_HOOKS_GEMINI_SETTINGS_PATH;
34397
34442
  return;
@@ -34435,7 +34480,7 @@ function readSettings2(scope = "global", target = "claude") {
34435
34480
  return value;
34436
34481
  }
34437
34482
  } catch (error61) {
34438
- console.warn(`[hooks] Failed to read settings at ${path}: ${error61 instanceof Error ? error61.message : String(error61)}`);
34483
+ console.warn("[hooks] Cannot read hook settings; repair the selected file before installation.");
34439
34484
  }
34440
34485
  const empty = {};
34441
34486
  if (!existsSync10(path))
@@ -34447,10 +34492,16 @@ function settingsIdentity(path) {
34447
34492
  return realpathSync5(path);
34448
34493
  return existsSync10(dirname7(path)) ? join10(realpathSync5(dirname7(path)), basename3(path)) : resolve7(path);
34449
34494
  }
34450
- function writeSettings(settings, scope = "global", target = "claude") {
34495
+ function writeSettings(settings, scope = "global", target = "claude", expectedSettingsSha256) {
34451
34496
  const path = getSettingsPath(scope, target);
34452
34497
  if (target === "codex")
34453
- return writeCodexSettings(path, settings);
34498
+ return writeCodexSettings(path, settings, expectedSettingsSha256);
34499
+ const captured = settingsPreimages.get(settings);
34500
+ if (expectedSettingsSha256 !== undefined) {
34501
+ if (!captured || captured.path !== path)
34502
+ throw new Error("Hook settings require a valid captured preimage.");
34503
+ assertSettingsPreimage(captured.text, expectedSettingsSha256);
34504
+ }
34454
34505
  if (target === "claude" && settingsIdentity(path) === settingsIdentity(join10(homedir5(), ".claude/settings.json"))) {
34455
34506
  const before = settingsPreimages.get(settings);
34456
34507
  if (!before || before.path !== path)
@@ -34462,6 +34513,8 @@ function writeSettings(settings, scope = "global", target = "claude") {
34462
34513
  `, { home: homedir5() }))
34463
34514
  return;
34464
34515
  }
34516
+ if (expectedSettingsSha256 !== undefined)
34517
+ return writeHookSettingsPreimage(path, settings, captured.text);
34465
34518
  const dir = dirname7(path);
34466
34519
  if (!existsSync10(dir)) {
34467
34520
  mkdirSync6(dir, { recursive: true });
@@ -34583,7 +34636,7 @@ function detectConflict(name, scope, target) {
34583
34636
  const meta3 = isNativeSafetyName(name) ? getHook(name) : resolveHookMeta(name);
34584
34637
  if (!meta3 || !meta3.matcher)
34585
34638
  return;
34586
- const events = new Set(getHookEvents(meta3));
34639
+ const events2 = new Set(getHookEvents(meta3));
34587
34640
  const registered = getRegisteredHooksForTarget(scope, target);
34588
34641
  for (const existingName of registered) {
34589
34642
  if (existingName === name)
@@ -34591,7 +34644,7 @@ function detectConflict(name, scope, target) {
34591
34644
  const existing = getHook(existingName);
34592
34645
  if (!existing || !existing.matcher)
34593
34646
  continue;
34594
- if (!getHookEvents(existing).some((event) => events.has(event)))
34647
+ if (!getHookEvents(existing).some((event) => events2.has(event)))
34595
34648
  continue;
34596
34649
  if (matchersOverlap(meta3.matcher.toLowerCase(), existing.matcher.toLowerCase())) {
34597
34650
  return `conflicts with '${existingName}' (same event ${meta3.event}, overlapping matcher '${existing.matcher}')`;
@@ -34619,7 +34672,7 @@ function detectRewriteConflict(name, scope, target, lookup = getHook) {
34619
34672
  }
34620
34673
  return;
34621
34674
  }
34622
- function installForTarget(name, scope, overwrite, target, profile, codewithMode = "fragment", codewithConfigPath, mementos) {
34675
+ function installForTarget(name, scope, overwrite, target, profile, codewithMode = "fragment", codewithConfigPath, mementos, expectedSettingsSha256) {
34623
34676
  const shortName = shortHookName(name);
34624
34677
  if (isNativeSafetyName(shortName) && (profile || target === "gemini" || target === "codewith" && codewithMode === "write")) {
34625
34678
  return { hook: shortName, success: false, error: "Bundled native safety guards support Claude/Codex and managed Codewith fragments, without registry profiles.", target };
@@ -34686,7 +34739,7 @@ function installForTarget(name, scope, overwrite, target, profile, codewithMode
34686
34739
  if (rewriteConflict) {
34687
34740
  return { hook: shortName, success: false, error: `Refused: ${rewriteConflict}`, scope, target };
34688
34741
  }
34689
- registerHook(shortName, scope, target, profile, mementos);
34742
+ registerHook(shortName, scope, target, profile, mementos, (settings, selectedScope, selectedTarget) => writeSettings(settings, selectedScope, selectedTarget, expectedSettingsSha256));
34690
34743
  return {
34691
34744
  hook: shortName,
34692
34745
  success: true,
@@ -34705,7 +34758,10 @@ function installForTarget(name, scope, overwrite, target, profile, codewithMode
34705
34758
  }
34706
34759
  }
34707
34760
  function installHook(name, options = {}) {
34708
- const { scope = "global", overwrite = false, target = "claude", profile, codewithMode = "fragment", codewithConfigPath, mementos } = options;
34761
+ const { scope = "global", overwrite = false, target = "claude", profile, codewithMode = "fragment", codewithConfigPath, mementos, expectedSettingsSha256 } = options;
34762
+ if (expectedSettingsSha256 !== undefined && (!/^(?:absent|[a-f0-9]{64})$/.test(expectedSettingsSha256) || !isNativeSafetyName(shortHookName(name)) || target !== "codex" && target !== "claude")) {
34763
+ return { hook: name, success: false, target, error: "An exact settings predecessor requires a Claude or Codex native safety guard." };
34764
+ }
34709
34765
  try {
34710
34766
  validateMementosRegistration(name, mementos);
34711
34767
  } catch (error61) {
@@ -34746,9 +34802,9 @@ function installHook(name, options = {}) {
34746
34802
  }
34747
34803
  return { ...claudeResult, target: "all" };
34748
34804
  }
34749
- return installForTarget(name, scope, overwrite, target, profile, codewithMode, codewithConfigPath, mementos);
34805
+ return installForTarget(name, scope, overwrite, target, profile, codewithMode, codewithConfigPath, mementos, expectedSettingsSha256);
34750
34806
  }
34751
- function registerHook(name, scope = "global", target = "claude", profile, mementos) {
34807
+ function registerHook(name, scope = "global", target = "claude", profile, mementos, write = writeSettings) {
34752
34808
  const meta3 = isNativeSafetyName(name) ? getHook(name) : resolveHookMeta(name);
34753
34809
  if (!meta3)
34754
34810
  return;
@@ -34777,7 +34833,7 @@ function registerHook(name, scope = "global", target = "claude", profile, mement
34777
34833
  }
34778
34834
  const retained = name === "mementos-context" && mementos === undefined ? retainedMementosRegistration(Object.values(settings.hooks).flatMap((groups) => Array.isArray(groups) ? groups.flatMap((group) => Array.isArray(group.hooks) ? group.hooks.map((hook) => hook.command).filter((command) => typeof command === "string") : []) : [])) : undefined;
34779
34835
  const hookCommand = isNativeSafetyName(name) ? installedNativeSafetyCommand(name) : (profile ? `hooks run ${name} --profile ${profile}` : `hooks run ${name}`) + mementosCommandSuffix(mementos ?? retained);
34780
- if (target === "codex") {
34836
+ if (target === "codex" || target === "claude" && isNativeSafetyName(name)) {
34781
34837
  const positions = [];
34782
34838
  for (const [event, groups] of Object.entries(settings.hooks)) {
34783
34839
  for (const entry of groups) {
@@ -34803,7 +34859,7 @@ function registerHook(name, scope = "global", target = "claude", profile, mement
34803
34859
  if (typeof meta3.timeoutSeconds === "number" && meta3.timeoutSeconds > 0)
34804
34860
  entry.hooks[index].timeout = meta3.timeoutSeconds;
34805
34861
  }
34806
- writeSettings(settings, scope, target);
34862
+ write(settings, scope, target);
34807
34863
  return;
34808
34864
  }
34809
34865
  }
@@ -34823,7 +34879,7 @@ function registerHook(name, scope = "global", target = "claude", profile, mement
34823
34879
  }
34824
34880
  settings.hooks[eventKey].push(entry);
34825
34881
  }
34826
- writeSettings(settings, scope, target);
34882
+ write(settings, scope, target);
34827
34883
  }
34828
34884
  function removeHookFromAllEvents(settings, name) {
34829
34885
  if (!settings.hooks)