@hasna/hooks 0.10.7 → 0.10.9

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
@@ -393,8 +393,46 @@ commands are credential-free and return compact JSON. Verification explicitly
393
393
  reports `nativeAdoptionVerified: false`: a fresh native Sumi tool invocation is
394
394
  still required to prove that the installed harness uses this binding.
395
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
+
396
430
  The owner-only `~/.hasna/hooks/native/sumi-trash-guard.json` pins the supervisor's
397
- 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
398
436
  `--expected-binding-sha256 <current-digest>` and preserves the previous record.
399
437
  An interrupted lock or unexpected current record requires reconciliation; setup
400
438
  does not overwrite it. This file is native configuration, not a Hooks app store.
package/bin/hooks-mcp.js CHANGED
@@ -3185,7 +3185,7 @@ function writeHookSettingsPreimage(path, settings, before) {
3185
3185
  // src/lib/claude-skills-coordination.ts
3186
3186
  import { createHash as createHash5 } from "crypto";
3187
3187
 
3188
- // ../../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
3189
3189
  import { existsSync as existsSync9 } from "fs";
3190
3190
  import { homedir as homedir4 } from "os";
3191
3191
  import { join as join8, resolve as resolve3 } from "path";
@@ -3198,6 +3198,7 @@ import { basename as basename2, isAbsolute as isAbsolute22, resolve as resolve22
3198
3198
  import { createHash as createHash16 } from "crypto";
3199
3199
  import { lstatSync as lstatSync20, readFileSync as readFileSync16, readdirSync as readdirSync9, realpathSync as realpathSync4 } from "fs";
3200
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";
3201
3202
  import { dirname as dirname32, isAbsolute as isAbsolute3, join as join72, resolve as resolve32 } from "path";
3202
3203
  import { lstatSync as lstatSync3, readFileSync as readFileSync6, opendirSync } from "fs";
3203
3204
  import { createHash as createHash22 } from "crypto";
@@ -10678,8 +10679,8 @@ function assertAgentPolicyCollections(policy) {
10678
10679
  text(source.path);
10679
10680
  requireBound(source.sha256 === null || typeof source.sha256 === "string" && /^[a-f0-9]{64}$/.test(source.sha256));
10680
10681
  if (source.hashMode !== undefined)
10681
- 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));
10682
- 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")
10683
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");
10684
10685
  if (source.hashMode === "claude-marketplace-registry" || source.hashMode === "claude-marketplace-registry-v2")
10685
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");
@@ -11174,7 +11175,7 @@ function hashDiscoveryPathFile(path, budget, changes) {
11174
11175
  }
11175
11176
  var package_default = {
11176
11177
  name: "@hasna/skills",
11177
- version: "0.9.14",
11178
+ version: "0.9.17",
11178
11179
  description: "Skills library for AI coding agents",
11179
11180
  type: "module",
11180
11181
  bin: {
@@ -12029,7 +12030,7 @@ function assertClaudeHookEventsReplacement(before, replacement, events) {
12029
12030
  need2(JSON.stringify(withoutOwnedEvents(original)) === JSON.stringify(withoutOwnedEvents(next)), `${label} update changed settings outside hooks.${label}`);
12030
12031
  }
12031
12032
  var PERSISTED_EFFORT_LEVELS = new Set(["low", "medium", "high", "xhigh"]);
12032
- function inferencePreference(key, child) {
12033
+ function inferencePreference(key, child, version) {
12033
12034
  if (key === "skipDangerousModePermissionPrompt") {
12034
12035
  need2(child.kind === "literal" && (child.value === "true" || child.value === "false"), "contains an invalid dialog acknowledgment");
12035
12036
  return false;
@@ -12042,7 +12043,7 @@ function inferencePreference(key, child) {
12042
12043
  return false;
12043
12044
  need2(child.kind === "object", "requires an object of model preferences");
12044
12045
  child.entries = child.entries.filter(([model, preferences]) => {
12045
- 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")
12046
12047
  return true;
12047
12048
  const effort = preferences.entries[0][1];
12048
12049
  need2(effort.kind === "string" && PERSISTED_EFFORT_LEVELS.has(effort.value), "contains an invalid persisted model effort level");
@@ -12053,7 +12054,7 @@ function inferencePreference(key, child) {
12053
12054
  function settingsDigest(text2, version = 1) {
12054
12055
  const value = parse3(text2);
12055
12056
  value.entries = value.entries.filter(([key, child]) => {
12056
- if (version === 2 && inferencePreference(key, child))
12057
+ if (version !== 1 && inferencePreference(key, child, version))
12057
12058
  return false;
12058
12059
  if (key === "model") {
12059
12060
  need2(child.kind === "string", "contains an invalid model selection");
@@ -12091,6 +12092,16 @@ function hashClaudeSettingsReplacementV2(text2, budget) {
12091
12092
  budget.remaining -= bytes;
12092
12093
  return settingsDigest(text2, 2);
12093
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
+ }
12094
12105
  var sha = (data) => createHash10("sha256").update(data).digest("hex");
12095
12106
  var ROOTS = NATIVE_SKILL_ROOTS;
12096
12107
  function assertSafePath(path) {
@@ -12544,6 +12555,8 @@ function applyAgentIntegration(plan) {
12544
12555
  parseManagedSkillPolicy(change.after);
12545
12556
  const aliases = plan.rootAliases ?? [];
12546
12557
  recheckRootAliases(aliases);
12558
+ for (const agent of plan.retainedReviewChecks?.agents ?? [])
12559
+ assertManagedAgentBridge(agent, { ...plan.retainedReviewChecks, dataDir: plan.dataDir });
12547
12560
  for (const agent of plan.managedAgentChecks?.agents ?? [])
12548
12561
  assertManagedAgentBridge(agent, { home: plan.managedAgentChecks.home, dataDir: plan.dataDir, projectDir: plan.managedAgentChecks.home });
12549
12562
  for (const binding of plan.discoveryBefore ?? []) {
@@ -13557,13 +13570,17 @@ function unchanged2(before) {
13557
13570
  const after = snapshot(before.file, before.privateFile, before.readOnlyPackage ? { readOnlyPackage: true } : {});
13558
13571
  need4(sameStat(before.stat, after.stat) && after.sha256 === before.sha256, "INPUT_CHANGED");
13559
13572
  }
13560
- 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"] };
13561
13575
  var proofs = new WeakMap;
13576
+ var sha2 = (text2) => createHash15("sha256").update(text2).digest("hex");
13577
+ var proofs2 = new WeakMap;
13562
13578
  function assertCodexHookDiscoveryRecovery(binding, witness) {
13563
- const proof = proofs.get(witness);
13579
+ const proof = proofs2.get(witness);
13564
13580
  need4(!!proof && binding.agent === "codex" && witness.discoverySha256 === sha2(JSON.stringify(binding)), "RECONCILE_DISCOVERY_RECOVERY_CHANGED");
13565
13581
  unchanged2(proof.before);
13566
13582
  unchanged2(proof.current);
13583
+ proof.recheckAdditions();
13567
13584
  }
13568
13585
  function verifiesCodexHookDiscoverySource(binding, source, currentDigest, witness) {
13569
13586
  if (!witness || source.path !== witness.configPath || source.hashMode !== "bytes")
@@ -13605,13 +13622,13 @@ function read2(path, changes) {
13605
13622
  return readFileSync16(path, "utf8");
13606
13623
  }
13607
13624
  function projected(source, changes, budget = discoveryByteBudget()) {
13608
- 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") {
13609
13626
  if (source.format !== undefined || source.fields !== undefined || source.managedPlugins !== undefined || typeof source.sha256 !== "string" || !/^[a-f0-9]{64}$/.test(source.sha256))
13610
13627
  throw new Error("Claude settings witnesses require exact typed metadata");
13611
- 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;
13612
13629
  if (current2 !== source.sha256)
13613
13630
  throw new Error(`Native discovery input changed; run skills hook install with a fresh discovery review: ${source.path}`);
13614
- 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;
13615
13632
  }
13616
13633
  if (source.hashMode === "claude-marketplace-registry" || source.hashMode === "claude-marketplace-registry-v2") {
13617
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))
@@ -13673,7 +13690,7 @@ function verifyAgentDiscovery(binding, codexRecovery) {
13673
13690
  safe4(root2);
13674
13691
  }
13675
13692
  function assertMarketplaceBinding(binding, source) {
13676
- 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"))
13677
13694
  throw new Error("Claude settings witnesses require explicit reviewed Claude configuration");
13678
13695
  if ((source.hashMode === "claude-marketplace-registry" || source.hashMode === "claude-marketplace-registry-v2") && (binding.agent !== "claude" || binding.method !== "reviewed"))
13679
13696
  throw new Error("Claude marketplace witnesses require explicit reviewed Claude discovery");
@@ -13682,7 +13699,10 @@ function rebindAgentDiscovery(binding, changes) {
13682
13699
  const budget = discoveryByteBudget();
13683
13700
  return { ...binding, sources: binding.sources.map((source) => {
13684
13701
  assertMarketplaceBinding(binding, source);
13685
- 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 };
13686
13706
  }) };
13687
13707
  }
13688
13708
  function assertProjectDiscovery(agent, directories, home, canonical4 = resolve14, reviewed) {
@@ -13794,6 +13814,19 @@ function resolveAgentDiscovery(options) {
13794
13814
  }
13795
13815
  }
13796
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
+ }
13797
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))
13798
13831
  throw new Error("Invalid --discovery-inputs version or agents");
13799
13832
  const review = options.reviewed?.agents.find((item) => item.agent === agent);
@@ -13804,7 +13837,7 @@ function resolveAgentDiscovery(options) {
13804
13837
  if (review.sources.some((source) => source.format !== undefined || source.fields !== undefined))
13805
13838
  throw new Error("Explicit discovery reviews require full source-file hashes");
13806
13839
  verifyAgentDiscovery(supplied);
13807
- 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)))
13808
13841
  throw new Error("Semantic settings witnesses must name the configured Claude configuration source");
13809
13842
  if (!review.sources.some((source) => source.path === canonical4(configPath)))
13810
13843
  throw new Error("Discovery review must include the agent configuration source");
@@ -34314,8 +34347,8 @@ var SESSION_RECONCILIATION_PLAN_TTL_MS = 5 * 60 * 1000;
34314
34347
  // src/lib/claude-skills-coordination.ts
34315
34348
  function coordinateClaudeSettings(before, replacement, options) {
34316
34349
  const prior = JSON.parse(before ?? "{}"), next = JSON.parse(replacement);
34317
- const events = new Set([...Object.keys(prior.hooks ?? {}), ...Object.keys(next.hooks ?? {})]);
34318
- 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]));
34319
34352
  const selected = changed.length ? changed : ["PreToolUse"];
34320
34353
  const plan = planClaudeHookEventsUpdate({
34321
34354
  ...options,
@@ -34603,7 +34636,7 @@ function detectConflict(name, scope, target) {
34603
34636
  const meta3 = isNativeSafetyName(name) ? getHook(name) : resolveHookMeta(name);
34604
34637
  if (!meta3 || !meta3.matcher)
34605
34638
  return;
34606
- const events = new Set(getHookEvents(meta3));
34639
+ const events2 = new Set(getHookEvents(meta3));
34607
34640
  const registered = getRegisteredHooksForTarget(scope, target);
34608
34641
  for (const existingName of registered) {
34609
34642
  if (existingName === name)
@@ -34611,7 +34644,7 @@ function detectConflict(name, scope, target) {
34611
34644
  const existing = getHook(existingName);
34612
34645
  if (!existing || !existing.matcher)
34613
34646
  continue;
34614
- if (!getHookEvents(existing).some((event) => events.has(event)))
34647
+ if (!getHookEvents(existing).some((event) => events2.has(event)))
34615
34648
  continue;
34616
34649
  if (matchersOverlap(meta3.matcher.toLowerCase(), existing.matcher.toLowerCase())) {
34617
34650
  return `conflicts with '${existingName}' (same event ${meta3.event}, overlapping matcher '${existing.matcher}')`;