@hasna/hooks 0.6.8 → 0.7.2

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.
Files changed (32) hide show
  1. package/README.md +1 -1
  2. package/bin/index.js +579 -113
  3. package/bin/serve.js +503 -38
  4. package/dist/config.d.ts +7 -5
  5. package/dist/db/pg-migrations.d.ts +1 -1
  6. package/dist/index.js +533 -71
  7. package/dist/openapi.d.ts +216 -0
  8. package/hooks/codewith-native-common.test.ts +25 -25
  9. package/hooks/codewith-native-common.ts +1 -1
  10. package/hooks/hook-autoformat/package.json +1 -1
  11. package/hooks/hook-desktopnotify/package.json +1 -1
  12. package/hooks/hook-envsetup/package.json +1 -1
  13. package/hooks/hook-knowledge-context/README.md +1 -1
  14. package/hooks/hook-knowledge-context/src/hook.test.ts +1 -1
  15. package/hooks/hook-permissionguard/package.json +1 -1
  16. package/hooks/hook-protectfiles/package.json +1 -1
  17. package/hooks/hook-spiral-detector/LICENSE +191 -0
  18. package/hooks/hook-spiral-detector/README.md +23 -0
  19. package/hooks/hook-spiral-detector/package.json +12 -0
  20. package/hooks/hook-spiral-detector/src/hook.test.ts +72 -0
  21. package/hooks/hook-spiral-detector/src/hook.ts +93 -0
  22. package/hooks/hook-workspace-repos-guard/README.md +4 -3
  23. package/hooks/hook-workspace-repos-guard/src/hook.test.ts +7 -7
  24. package/hooks/hook-workspace-repos-guard/src/hook.ts +5 -3
  25. package/hooks/mention-context/src/hasna-mention-context.py +2 -2
  26. package/hooks/mention-context/src/hasna-mention-warm.py +1 -1
  27. package/hooks/pre-bash/README.md +1 -1
  28. package/hooks/prompt-guard/README.md +1 -1
  29. package/hooks/session-start/README.md +1 -1
  30. package/hooks/stop-sync/README.md +1 -1
  31. package/hooks/worktree-guard/README.md +1 -1
  32. package/package.json +10 -6
package/bin/serve.js CHANGED
@@ -368,6 +368,16 @@ var HOOKS = [
368
368
  matcher: "",
369
369
  tags: ["errors", "failures", "logging", "debugging"]
370
370
  },
371
+ {
372
+ name: "spiral-detector",
373
+ displayName: "Spiral Detector",
374
+ description: "Interrupts sessions after five consecutive identical command failures",
375
+ version: "0.1.0",
376
+ category: "Observability",
377
+ event: "PostToolUse",
378
+ matcher: "",
379
+ tags: ["spiral", "failures", "retries", "safety", "interrupt"]
380
+ },
371
381
  {
372
382
  name: "stylescheck",
373
383
  displayName: "Styles Check",
@@ -524,8 +534,8 @@ function getHook(name) {
524
534
  }
525
535
 
526
536
  // src/lib/manifest.ts
527
- import { existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, writeFileSync } from "fs";
528
- import { basename, dirname, isAbsolute, join as join2, normalize, resolve, sep } from "path";
537
+ import { existsSync, mkdirSync, readFileSync as readFileSync2, readdirSync, realpathSync, writeFileSync } from "fs";
538
+ import { basename, dirname, isAbsolute, join as join3, normalize, resolve, sep } from "path";
529
539
 
530
540
  // ../../node_modules/.bun/zod@3.25.76/node_modules/zod/v3/external.js
531
541
  var exports_external = {};
@@ -4500,26 +4510,303 @@ var coerce = {
4500
4510
  date: (arg) => ZodDate.create({ ...arg, coerce: true })
4501
4511
  };
4502
4512
  var NEVER = INVALID;
4503
- // src/config.ts
4513
+ // ../contracts/dist/client/transport.js
4514
+ import { readFileSync, statSync } from "fs";
4504
4515
  import { join } from "path";
4516
+ function envToken(name) {
4517
+ return name.toUpperCase().replace(/-/g, "_");
4518
+ }
4519
+ function clientTransportEnvKeys(name) {
4520
+ const envSegment = envToken(name);
4521
+ return {
4522
+ apiUrlKeys: [`HASNA_${envSegment}_API_URL`, `${envSegment}_API_URL`],
4523
+ apiKeyKeys: [`HASNA_${envSegment}_API_KEY`, `${envSegment}_API_KEY`]
4524
+ };
4525
+ }
4526
+ function credentialOverrideEnvKey(name) {
4527
+ return `HASNA_${envToken(name)}_API_KEY_OVERRIDE`;
4528
+ }
4529
+ var CREDENTIAL_PROFILE_ENV_KEY = "HASNA_PROFILE";
4530
+
4531
+ class CredentialResolutionError extends Error {
4532
+ appName;
4533
+ attempted;
4534
+ constructor(appName, message, attempted) {
4535
+ super(message);
4536
+ this.name = "CredentialResolutionError";
4537
+ this.appName = appName;
4538
+ this.attempted = attempted;
4539
+ }
4540
+ }
4541
+ var HASNA_STATE_DIR = ".hasna";
4542
+ var FLEET_CREDENTIAL_DIR = "cloud";
4543
+ var CONFIG_DIR = ".config";
4544
+ var CONFIG_NAMESPACE = "hasna";
4545
+ var MAX_CREDENTIAL_FILE_BYTES = 64 * 1024;
4546
+ var SAFE_APP_SLUG = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/;
4547
+ var SAFE_PROFILE = /^[A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?$/;
4548
+ var ILLEGAL_IN_HEADER_VALUE = /[^\t\x20-\x7e]/;
4549
+ function homeDir(env) {
4550
+ const home = env.HOME?.trim();
4551
+ return home ? home : null;
4552
+ }
4553
+ function credentialDiskSources(name, env) {
4554
+ return profileDiskSources(name, env, null);
4555
+ }
4556
+ function profileDiskSources(name, env, profile) {
4557
+ const home = homeDir(env);
4558
+ if (!home || !SAFE_APP_SLUG.test(name))
4559
+ return [];
4560
+ const stem = profile ? `${name}.${profile}` : name;
4561
+ const configStem = profile ? `${name}-${profile}` : name;
4562
+ return [
4563
+ join(home, HASNA_STATE_DIR, FLEET_CREDENTIAL_DIR, `${stem}.env`),
4564
+ join(home, CONFIG_DIR, CONFIG_NAMESPACE, `${configStem}-cloud.env`)
4565
+ ];
4566
+ }
4567
+ function parseEnvFile(text) {
4568
+ const values = new Map;
4569
+ for (const rawLine of text.split(/\r?\n/)) {
4570
+ const line = rawLine.trim();
4571
+ if (line.length === 0 || line.startsWith("#"))
4572
+ continue;
4573
+ const withoutExport = line.startsWith("export ") ? line.slice("export ".length).trim() : line;
4574
+ const equals = withoutExport.indexOf("=");
4575
+ if (equals <= 0)
4576
+ continue;
4577
+ const key = withoutExport.slice(0, equals).trim();
4578
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key))
4579
+ continue;
4580
+ let value = withoutExport.slice(equals + 1).trim();
4581
+ const quote = value[0];
4582
+ if (quote === '"' || quote === "'") {
4583
+ if (value.length < 2 || !value.endsWith(quote))
4584
+ continue;
4585
+ value = value.slice(1, -1);
4586
+ }
4587
+ if (value.length === 0)
4588
+ continue;
4589
+ values.set(key, value);
4590
+ }
4591
+ return values;
4592
+ }
4593
+ function readAppConfigFile(path) {
4594
+ let text;
4595
+ try {
4596
+ const stats = statSync(path);
4597
+ if (!stats.isFile() || stats.size > MAX_CREDENTIAL_FILE_BYTES)
4598
+ return null;
4599
+ text = readFileSync(path, "utf8");
4600
+ } catch {
4601
+ return null;
4602
+ }
4603
+ return parseEnvFile(text);
4604
+ }
4605
+ function readCredentialFile(path, apiKeyKeys) {
4606
+ const values = readAppConfigFile(path);
4607
+ if (!values)
4608
+ return null;
4609
+ for (const key of apiKeyKeys) {
4610
+ const value = values.get(key)?.trim();
4611
+ if (value)
4612
+ return value;
4613
+ }
4614
+ return null;
4615
+ }
4616
+ function assertUsableCredential(appName, source, value) {
4617
+ if (!ILLEGAL_IN_HEADER_VALUE.test(value))
4618
+ return;
4619
+ throw new CredentialResolutionError(appName, `The credential from ${source} contains characters that cannot be sent in an HTTP header ` + `(a control character or non-ASCII byte). A file written with CR-only line endings is the usual ` + `cause. Rewrite that credential file with one LF-terminated KEY=value line. ` + `The value is not shown here, and is deliberately never logged.`, [source]);
4620
+ }
4621
+ var INSPECT_CUSTOM = Symbol.for("nodejs.util.inspect.custom");
4622
+ var CREDENTIAL_SEAL = Symbol.for("hasna:contracts:sealedCredential");
4623
+ function sealCredential(fields) {
4624
+ const { apiKey } = fields;
4625
+ const visible = {
4626
+ tier: fields.tier,
4627
+ source: fields.source,
4628
+ deliberate: fields.deliberate,
4629
+ deprecated: fields.deprecated,
4630
+ diskCandidates: Object.freeze([...fields.diskCandidates]),
4631
+ warning: fields.warning
4632
+ };
4633
+ const sealed = { ...visible };
4634
+ Object.defineProperty(sealed, "apiKey", {
4635
+ value: apiKey,
4636
+ enumerable: false,
4637
+ writable: false,
4638
+ configurable: false
4639
+ });
4640
+ Object.defineProperty(sealed, INSPECT_CUSTOM, {
4641
+ value: () => ({ ...visible, apiKey: "[redacted]" }),
4642
+ enumerable: false,
4643
+ writable: false,
4644
+ configurable: false
4645
+ });
4646
+ Object.defineProperty(sealed, CREDENTIAL_SEAL, {
4647
+ value: true,
4648
+ enumerable: false,
4649
+ writable: false,
4650
+ configurable: false
4651
+ });
4652
+ return Object.freeze(sealed);
4653
+ }
4654
+ function firstEnvValue(env, keys) {
4655
+ for (const key of keys) {
4656
+ const value = env[key]?.trim();
4657
+ if (value)
4658
+ return { key, value };
4659
+ }
4660
+ return null;
4661
+ }
4662
+ var DEPRECATION_REGISTRY = Symbol.for("hasna:contracts:credentialDeprecationNotices");
4663
+ function deprecationNotified() {
4664
+ const host = globalThis;
4665
+ const existing = host[DEPRECATION_REGISTRY];
4666
+ if (existing instanceof Set)
4667
+ return existing;
4668
+ const created = new Set;
4669
+ host[DEPRECATION_REGISTRY] = created;
4670
+ return created;
4671
+ }
4672
+ function defaultDeprecationSink(message) {
4673
+ if (typeof process !== "undefined" && process.stderr) {
4674
+ process.stderr.write(`${message}
4675
+ `);
4676
+ }
4677
+ }
4678
+ function resolveCredential(name, env, options = {}) {
4679
+ const { apiKeyKeys } = clientTransportEnvKeys(name);
4680
+ const diskPaths = credentialDiskSources(name, env);
4681
+ const explicitKey = options.apiKey?.trim();
4682
+ if (explicitKey) {
4683
+ assertUsableCredential(name, "the explicit apiKey argument", explicitKey);
4684
+ return sealCredential({
4685
+ apiKey: explicitKey,
4686
+ tier: "argument",
4687
+ source: "explicit apiKey argument",
4688
+ deliberate: true,
4689
+ deprecated: false,
4690
+ diskCandidates: diskPaths,
4691
+ warning: null
4692
+ });
4693
+ }
4694
+ const overrideKeyName = credentialOverrideEnvKey(name);
4695
+ const overrideRaw = env[overrideKeyName];
4696
+ if (overrideRaw !== undefined) {
4697
+ const override = overrideRaw.trim();
4698
+ if (!override) {
4699
+ throw new CredentialResolutionError(name, `${overrideKeyName} is set but empty. It is a deliberate override, so it is not resolved around: ` + `either give it a real key or unset it to fall back to the credential on disk.`, [overrideKeyName]);
4700
+ }
4701
+ assertUsableCredential(name, overrideKeyName, override);
4702
+ return sealCredential({
4703
+ apiKey: override,
4704
+ tier: "override",
4705
+ source: overrideKeyName,
4706
+ deliberate: true,
4707
+ deprecated: false,
4708
+ diskCandidates: diskPaths,
4709
+ warning: null
4710
+ });
4711
+ }
4712
+ const profile = options.profile?.trim() || env[CREDENTIAL_PROFILE_ENV_KEY]?.trim();
4713
+ if (profile) {
4714
+ const profileSource = options.profile?.trim() ? "explicit profile argument" : CREDENTIAL_PROFILE_ENV_KEY;
4715
+ if (!SAFE_PROFILE.test(profile)) {
4716
+ throw new CredentialResolutionError(name, `Profile name from ${profileSource} is not usable in a path. ` + `Use letters, digits, dot, dash, or underscore.`, [profileSource]);
4717
+ }
4718
+ const paths = profileDiskSources(name, env, profile);
4719
+ for (const path of paths) {
4720
+ const value = readCredentialFile(path, apiKeyKeys);
4721
+ if (value) {
4722
+ assertUsableCredential(name, path, value);
4723
+ return sealCredential({
4724
+ apiKey: value,
4725
+ tier: "profile",
4726
+ source: path,
4727
+ deliberate: true,
4728
+ deprecated: false,
4729
+ diskCandidates: paths,
4730
+ warning: null
4731
+ });
4732
+ }
4733
+ }
4734
+ throw new CredentialResolutionError(name, `Profile '${profile}' (from ${profileSource}) has no ${apiKeyKeys[0]} for '${name}'. ` + `Looked in: ${paths.join(", ") || "<no HOME in this environment>"}. ` + `A profile names WHICH identity to use, so it is never resolved around \u2014 ` + `create the profile's credential file or unset ${CREDENTIAL_PROFILE_ENV_KEY}.`, paths);
4735
+ }
4736
+ const diskHits = diskPaths.map((path) => ({ path, value: readCredentialFile(path, apiKeyKeys) })).filter((hit) => hit.value !== null);
4737
+ if (diskHits.length > 0) {
4738
+ const winner = diskHits[0];
4739
+ assertUsableCredential(name, winner.path, winner.value);
4740
+ const divergentSources = [
4741
+ ...diskHits.slice(1).filter((hit) => hit.value !== winner.value).map((hit) => hit.path),
4742
+ ...(() => {
4743
+ const legacyHit = firstEnvValue(env, apiKeyKeys);
4744
+ return legacyHit && legacyHit.value !== winner.value ? [legacyHit.key] : [];
4745
+ })()
4746
+ ];
4747
+ const warning = divergentSources.length > 0 ? `Credential sources disagree for '${name}': ${winner.path} and ` + `${divergentSources.join(", ")} hold different keys. ${winner.path} wins, because a file on ` + `disk is re-read on every call while an environment variable is a snapshot. Reconcile them \u2014 ` + `a rotation that updated only one leaves the other to fail 401 wherever it is loaded first.` : null;
4748
+ return sealCredential({
4749
+ apiKey: winner.value,
4750
+ tier: "disk",
4751
+ source: winner.path,
4752
+ deliberate: false,
4753
+ deprecated: false,
4754
+ diskCandidates: diskPaths,
4755
+ warning
4756
+ });
4757
+ }
4758
+ const legacy = firstEnvValue(env, apiKeyKeys);
4759
+ if (legacy) {
4760
+ assertUsableCredential(name, legacy.key, legacy.value);
4761
+ const where = diskPaths.length > 0 ? `Put the current key in ${diskPaths[0]} \u2014 it is re-read on every call, so rotations take effect immediately.` : `This environment has no HOME, so no credential file could be consulted at all; the disk tier is ` + `unavailable here and this process will keep using the environment snapshot.`;
4762
+ const message = `[${name}] DEPRECATED: the API key came from ${legacy.key} in this process's environment. ` + `Environment variables are a snapshot taken when this process started, so a shell that started ` + `before a key rotation keeps using the old key until it exits. ${where}`;
4763
+ const sink = options.onDeprecation ?? defaultDeprecationSink;
4764
+ const notified = deprecationNotified();
4765
+ if (!notified.has(name)) {
4766
+ notified.add(name);
4767
+ sink(message);
4768
+ }
4769
+ return sealCredential({
4770
+ apiKey: legacy.value,
4771
+ tier: "legacy-env",
4772
+ source: legacy.key,
4773
+ deliberate: false,
4774
+ deprecated: true,
4775
+ diskCandidates: diskPaths,
4776
+ warning: message
4777
+ });
4778
+ }
4779
+ return null;
4780
+ }
4781
+ var IDEMPOTENT_METHODS = new Set(["GET", "HEAD", "PUT", "DELETE", "OPTIONS"]);
4782
+ var AUTHORITY_OVERRIDE_HEADERS = new Set([
4783
+ "host",
4784
+ ":authority",
4785
+ "forwarded",
4786
+ "x-forwarded-host",
4787
+ "x-original-host"
4788
+ ]);
4789
+
4790
+ // src/config.ts
4791
+ import { join as join2 } from "path";
4505
4792
  import { homedir } from "os";
4506
4793
  function getHooksDataDir() {
4507
4794
  const explicit = process.env.HASNA_HOOKS_DATA_DIR ?? process.env.HOOKS_DATA_DIR;
4508
4795
  if (explicit)
4509
4796
  return explicit;
4510
- return join(homedir(), ".hasna", "hooks");
4797
+ return join2(homedir(), ".hasna", "hooks");
4511
4798
  }
4512
4799
  function getCustomHooksDir() {
4513
- return join(getHooksDataDir(), "hooks");
4800
+ return join2(getHooksDataDir(), "hooks");
4514
4801
  }
4515
4802
  function getLockPath() {
4516
4803
  const explicit = process.env.HASNA_HOOKS_LOCK_PATH ?? process.env.HOOKS_LOCK_PATH;
4517
4804
  if (explicit)
4518
4805
  return explicit;
4519
- return join(getHooksDataDir(), "hooks.lock");
4806
+ return join2(getHooksDataDir(), "hooks.lock");
4520
4807
  }
4521
4808
  function resolveApiKey() {
4522
- return process.env.HASNA_HOOKS_API_KEY ?? process.env.HOOKS_API_KEY;
4809
+ return resolveCredential("hooks", process.env)?.apiKey;
4523
4810
  }
4524
4811
 
4525
4812
  // src/lib/semver.ts
@@ -4575,7 +4862,7 @@ function realpathNearest(path) {
4575
4862
  tail.unshift(basename(cur));
4576
4863
  cur = parent;
4577
4864
  }
4578
- return join2(realpathSync(cur), ...tail);
4865
+ return join3(realpathSync(cur), ...tail);
4579
4866
  }
4580
4867
  function assertContainedReal(root, target, raw) {
4581
4868
  const rootReal = realpathSync(root);
@@ -4608,25 +4895,25 @@ function resolveScript(manifest, manifestDir) {
4608
4895
  if (isInlineScript(manifest))
4609
4896
  return { path: scriptRelFor(manifest), content: manifest.script };
4610
4897
  const rel = scriptRelFor(manifest);
4611
- const resolved = assertContained(join2(manifestDir, rel), manifestDir, manifest.script);
4898
+ const resolved = assertContained(join3(manifestDir, rel), manifestDir, manifest.script);
4612
4899
  if (!existsSync(resolved)) {
4613
4900
  throw new Error(`manifest script file not found: ${resolved}`);
4614
4901
  }
4615
- return { path: rel, content: readFileSync(resolved, "utf-8") };
4902
+ return { path: rel, content: readFileSync2(resolved, "utf-8") };
4616
4903
  }
4617
4904
  function customHookDir(name) {
4618
- return join2(getCustomHooksDir(), shortManifestName(name));
4905
+ return join3(getCustomHooksDir(), shortManifestName(name));
4619
4906
  }
4620
4907
  function readCustomManifest(name) {
4621
4908
  const dir = customHookDir(name);
4622
- const manifestPath = join2(dir, "manifest.json");
4909
+ const manifestPath = join3(dir, "manifest.json");
4623
4910
  if (!existsSync(manifestPath))
4624
4911
  return;
4625
4912
  try {
4626
- const manifest = parseManifest(readFileSync(manifestPath, "utf-8"));
4913
+ const manifest = parseManifest(readFileSync2(manifestPath, "utf-8"));
4627
4914
  const script = resolveScript(manifest, dir);
4628
4915
  const scriptIsInline = isInlineScript(manifest);
4629
- return { manifest, scriptPath: join2(dir, script.path), scriptContent: script.content, scriptIsInline };
4916
+ return { manifest, scriptPath: join3(dir, script.path), scriptContent: script.content, scriptIsInline };
4630
4917
  } catch (err) {
4631
4918
  if (err instanceof ScriptContainmentError)
4632
4919
  throw err;
@@ -4655,13 +4942,13 @@ function readdirSafe(dir) {
4655
4942
 
4656
4943
  // src/lib/store.ts
4657
4944
  import { createHash } from "crypto";
4658
- import { existsSync as existsSync4, mkdirSync as mkdirSync3, readFileSync as readFileSync3, writeFileSync as writeFileSync2, renameSync } from "fs";
4945
+ import { existsSync as existsSync4, mkdirSync as mkdirSync3, readFileSync as readFileSync4, writeFileSync as writeFileSync2, renameSync } from "fs";
4659
4946
  import { readFile } from "fs/promises";
4660
4947
 
4661
4948
  // src/db/index.ts
4662
4949
  import { Database } from "bun:sqlite";
4663
4950
  import { existsSync as existsSync3, mkdirSync as mkdirSync2, cpSync } from "fs";
4664
- import { join as join4 } from "path";
4951
+ import { join as join5 } from "path";
4665
4952
  import { homedir as homedir3 } from "os";
4666
4953
 
4667
4954
  // src/db/schema.ts
@@ -4834,8 +5121,8 @@ function runMigrations(db) {
4834
5121
  }
4835
5122
 
4836
5123
  // src/db/legacy-import.ts
4837
- import { existsSync as existsSync2, readdirSync as readdirSync2, readFileSync as readFileSync2 } from "fs";
4838
- import { join as join3 } from "path";
5124
+ import { existsSync as existsSync2, readdirSync as readdirSync2, readFileSync as readFileSync3 } from "fs";
5125
+ import { join as join4 } from "path";
4839
5126
  import { homedir as homedir2 } from "os";
4840
5127
  var META_KEY = "legacy_import_done";
4841
5128
  function ensureMetaTable(db) {
@@ -4860,7 +5147,7 @@ function nanoid() {
4860
5147
  function importJsonlFile(db, filePath) {
4861
5148
  let count = 0;
4862
5149
  try {
4863
- const lines = readFileSync2(filePath, "utf-8").split(`
5150
+ const lines = readFileSync3(filePath, "utf-8").split(`
4864
5151
  `).filter(Boolean);
4865
5152
  for (const line of lines) {
4866
5153
  try {
@@ -4886,7 +5173,7 @@ function importJsonlFile(db, filePath) {
4886
5173
  function importErrorsLog(db, filePath) {
4887
5174
  let count = 0;
4888
5175
  try {
4889
- const lines = readFileSync2(filePath, "utf-8").split(`
5176
+ const lines = readFileSync3(filePath, "utf-8").split(`
4890
5177
  `).filter(Boolean);
4891
5178
  const linePattern = /^\[(.+?)\]\s+(?:\[session:(\S+)\]\s+)?(.+?)\s+\u2014\s+(.+)$/;
4892
5179
  for (const line of lines) {
@@ -4911,25 +5198,25 @@ function importErrorsLog(db, filePath) {
4911
5198
  } catch {}
4912
5199
  return count;
4913
5200
  }
4914
- function runLegacyImport(db, homeDir = homedir2()) {
5201
+ function runLegacyImport(db, homeDir2 = homedir2()) {
4915
5202
  try {
4916
5203
  if (isAlreadyDone(db))
4917
5204
  return;
4918
5205
  let total = 0;
4919
- const claudeProjectsDir = join3(homeDir, ".claude", "projects");
5206
+ const claudeProjectsDir = join4(homeDir2, ".claude", "projects");
4920
5207
  if (existsSync2(claudeProjectsDir)) {
4921
5208
  try {
4922
5209
  const projectDirs = readdirSync2(claudeProjectsDir);
4923
5210
  for (const dir of projectDirs) {
4924
- const projectDir = join3(claudeProjectsDir, dir);
5211
+ const projectDir = join4(claudeProjectsDir, dir);
4925
5212
  try {
4926
5213
  const files = readdirSync2(projectDir);
4927
5214
  for (const file of files) {
4928
5215
  if (file.match(/^session-log-\d{4}-\d{2}-\d{2}\.jsonl$/)) {
4929
- total += importJsonlFile(db, join3(projectDir, file));
5216
+ total += importJsonlFile(db, join4(projectDir, file));
4930
5217
  }
4931
5218
  if (file === "errors.log") {
4932
- total += importErrorsLog(db, join3(projectDir, file));
5219
+ total += importErrorsLog(db, join4(projectDir, file));
4933
5220
  }
4934
5221
  }
4935
5222
  } catch {}
@@ -4967,10 +5254,10 @@ function resolveDataDir() {
4967
5254
  const explicit = process.env.HASNA_HOOKS_DATA_DIR ?? process.env.HOOKS_DATA_DIR;
4968
5255
  if (explicit)
4969
5256
  return explicit;
4970
- const newDir = join4(homedir3(), ".hasna", "hooks");
4971
- const oldDir = join4(homedir3(), ".hooks");
5257
+ const newDir = join5(homedir3(), ".hasna", "hooks");
5258
+ const oldDir = join5(homedir3(), ".hooks");
4972
5259
  if (!existsSync3(newDir) && existsSync3(oldDir)) {
4973
- mkdirSync2(join4(homedir3(), ".hasna"), { recursive: true });
5260
+ mkdirSync2(join5(homedir3(), ".hasna"), { recursive: true });
4974
5261
  cpSync(oldDir, newDir, { recursive: true });
4975
5262
  }
4976
5263
  return newDir;
@@ -4980,7 +5267,7 @@ function getDbPath() {
4980
5267
  if (explicitDb)
4981
5268
  return explicitDb;
4982
5269
  const dataDir = resolveDataDir();
4983
- return join4(dataDir, "hooks.db");
5270
+ return join5(dataDir, "hooks.db");
4984
5271
  }
4985
5272
  function ensureDir(dbPath) {
4986
5273
  const dir = dbPath.substring(0, dbPath.lastIndexOf("/"));
@@ -5079,7 +5366,7 @@ function readLock() {
5079
5366
  return { hooks: {} };
5080
5367
  let raw;
5081
5368
  try {
5082
- raw = readFileSync3(path, "utf-8");
5369
+ raw = readFileSync4(path, "utf-8");
5083
5370
  } catch {
5084
5371
  throw new LockFileError(`hooks.lock (${path}) could not be read. Repair: fix permissions or move the file aside and run 'hooks sync'.`);
5085
5372
  }
@@ -5111,7 +5398,7 @@ function setPinnedHook(name, entry) {
5111
5398
  return writeLock(lock);
5112
5399
  }
5113
5400
  function retrustHook(name, scriptPath, version, source) {
5114
- const actual = sha256Of(readFileSync3(scriptPath));
5401
+ const actual = sha256Of(readFileSync4(scriptPath));
5115
5402
  const db = getDb();
5116
5403
  upsertHookRecord(db, {
5117
5404
  name,
@@ -5125,14 +5412,14 @@ function retrustHook(name, scriptPath, version, source) {
5125
5412
  }
5126
5413
 
5127
5414
  // src/lib/resolve.ts
5128
- import { dirname as dirname2, join as join5 } from "path";
5415
+ import { dirname as dirname2, join as join6 } from "path";
5129
5416
  import { existsSync as existsSync5 } from "fs";
5130
5417
  import { fileURLToPath } from "url";
5131
5418
  function resolveBundledHooksDir(moduleDir = dirname2(fileURLToPath(import.meta.url))) {
5132
- const sourceLayout = join5(moduleDir, "..", "..", "hooks");
5133
- if (existsSync5(join5(sourceLayout, "hook-gitguard")))
5419
+ const sourceLayout = join6(moduleDir, "..", "..", "hooks");
5420
+ if (existsSync5(join6(sourceLayout, "hook-gitguard")))
5134
5421
  return sourceLayout;
5135
- return join5(moduleDir, "..", "hooks");
5422
+ return join6(moduleDir, "..", "hooks");
5136
5423
  }
5137
5424
  function resolveHookDir(name) {
5138
5425
  const custom2 = readCustomManifest(name);
@@ -5143,10 +5430,10 @@ function resolveHookDir(name) {
5143
5430
  return;
5144
5431
  const short = shortManifestName(name);
5145
5432
  const bundledBase = resolveBundledHooksDir();
5146
- const direct = join5(bundledBase, short);
5433
+ const direct = join6(bundledBase, short);
5147
5434
  if (existsSync5(direct))
5148
5435
  return direct;
5149
- const prefixed = join5(bundledBase, `hook-${short}`);
5436
+ const prefixed = join6(bundledBase, `hook-${short}`);
5150
5437
  return existsSync5(prefixed) ? prefixed : undefined;
5151
5438
  }
5152
5439
  function resolveScriptPath(name) {
@@ -5156,7 +5443,7 @@ function resolveScriptPath(name) {
5156
5443
  const dir = resolveHookDir(name);
5157
5444
  if (!dir)
5158
5445
  return;
5159
- const script = join5(dir, "src", "hook.ts");
5446
+ const script = join6(dir, "src", "hook.ts");
5160
5447
  return existsSync5(script) ? script : undefined;
5161
5448
  }
5162
5449
  function resolveHookMeta(name) {
@@ -5208,6 +5495,11 @@ function resolveHook(name) {
5208
5495
  };
5209
5496
  }
5210
5497
 
5498
+ // src/serve.ts
5499
+ import { readFileSync as readFileSync5 } from "fs";
5500
+ import { dirname as dirname3, join as join7 } from "path";
5501
+ import { fileURLToPath as fileURLToPath2 } from "url";
5502
+
5211
5503
  // src/lib/secure-compare.ts
5212
5504
  function secureEqual(a, b) {
5213
5505
  const enc = new TextEncoder;
@@ -5221,10 +5513,169 @@ function secureEqual(a, b) {
5221
5513
  return diff === 0;
5222
5514
  }
5223
5515
 
5516
+ // src/openapi.ts
5517
+ var openApiDocument = {
5518
+ openapi: "3.1.0",
5519
+ info: {
5520
+ title: "Hooks Registry API",
5521
+ version: "0.1.0",
5522
+ description: "Local registry HTTP API for @hasna/hooks: catalog, lock, and artifact reads are open; publishing (PUT /api/v1/hooks) requires the API key (authorization: Bearer or x-api-key)."
5523
+ },
5524
+ servers: [{ url: "/" }],
5525
+ paths: {
5526
+ "/health": {
5527
+ get: {
5528
+ summary: "Liveness probe",
5529
+ operationId: "getHealth",
5530
+ responses: {
5531
+ "200": {
5532
+ description: "Service is alive",
5533
+ content: {
5534
+ "application/json": {
5535
+ schema: { type: "object", additionalProperties: true }
5536
+ }
5537
+ }
5538
+ }
5539
+ }
5540
+ }
5541
+ },
5542
+ "/ready": {
5543
+ get: {
5544
+ summary: "Readiness probe \u2014 the hooks.lock store is readable",
5545
+ operationId: "getReady",
5546
+ responses: {
5547
+ "200": { description: "Lock store is readable" },
5548
+ "503": { description: "Lock store is not readable" }
5549
+ }
5550
+ }
5551
+ },
5552
+ "/version": {
5553
+ get: {
5554
+ summary: "Package version",
5555
+ operationId: "getVersion",
5556
+ responses: {
5557
+ "200": {
5558
+ description: "The installed @hasna/hooks version",
5559
+ content: {
5560
+ "application/json": {
5561
+ schema: { type: "object", properties: { version: { type: "string" } } }
5562
+ }
5563
+ }
5564
+ }
5565
+ }
5566
+ }
5567
+ },
5568
+ "/openapi.json": {
5569
+ get: {
5570
+ summary: "This OpenAPI document",
5571
+ operationId: "getOpenApi",
5572
+ responses: { "200": { description: "OpenAPI 3.1 document" } }
5573
+ }
5574
+ },
5575
+ "/api/v1/catalog": {
5576
+ get: {
5577
+ summary: "List the full hook catalog with versions and sha256",
5578
+ operationId: "getCatalog",
5579
+ responses: {
5580
+ "200": {
5581
+ description: "Catalog entries",
5582
+ content: {
5583
+ "application/json": {
5584
+ schema: {
5585
+ type: "object",
5586
+ properties: {
5587
+ hooks: {
5588
+ type: "array",
5589
+ items: {
5590
+ type: "object",
5591
+ properties: {
5592
+ name: { type: "string" },
5593
+ version: { type: "string" },
5594
+ sha256: { type: "string" },
5595
+ events: { type: "array", items: { type: "string" } }
5596
+ }
5597
+ }
5598
+ }
5599
+ }
5600
+ }
5601
+ }
5602
+ }
5603
+ }
5604
+ }
5605
+ }
5606
+ },
5607
+ "/api/v1/lock": {
5608
+ get: {
5609
+ summary: "Read the published hooks.lock with per-hook versions",
5610
+ operationId: "getLock",
5611
+ responses: { "200": { description: "The lock file" } }
5612
+ }
5613
+ },
5614
+ "/api/v1/hooks/{name}/{version}": {
5615
+ get: {
5616
+ summary: "Fetch one hook's manifest and script",
5617
+ operationId: "getHookArtifact",
5618
+ parameters: [
5619
+ { name: "name", in: "path", required: true, schema: { type: "string" } },
5620
+ { name: "version", in: "path", required: true, schema: { type: "string" } }
5621
+ ],
5622
+ responses: {
5623
+ "200": {
5624
+ description: "Hook artifact; x-hook-sha256 header carries the script hash"
5625
+ },
5626
+ "400": { description: "Invalid URL encoding or semver version" },
5627
+ "404": { description: "Hook not found locally" }
5628
+ }
5629
+ }
5630
+ },
5631
+ "/api/v1/hooks": {
5632
+ put: {
5633
+ summary: "Publish/retrust a hook (requires the API key)",
5634
+ operationId: "putHook",
5635
+ requestBody: {
5636
+ required: true,
5637
+ content: {
5638
+ "application/json": {
5639
+ schema: {
5640
+ type: "object",
5641
+ properties: { name: { type: "string" }, version: { type: "string" } }
5642
+ }
5643
+ }
5644
+ }
5645
+ },
5646
+ responses: {
5647
+ "200": { description: "Hook retrusted and pin updated" },
5648
+ "401": { description: "Missing or invalid API key" },
5649
+ "404": { description: "Hook not found in local store" },
5650
+ "409": { description: "Version mismatch with the local store" }
5651
+ },
5652
+ security: [{ apiKey: [] }]
5653
+ }
5654
+ }
5655
+ },
5656
+ components: {
5657
+ securitySchemes: {
5658
+ apiKey: {
5659
+ type: "http",
5660
+ scheme: "bearer",
5661
+ description: "The registry API key (HASNA_HOOKS_API_KEY / HOOKS_API_KEY)."
5662
+ }
5663
+ }
5664
+ }
5665
+ };
5666
+
5224
5667
  // src/serve.ts
5225
5668
  var DEFAULT_SERVE_PORT = 39428;
5226
5669
  var SERVE_HOST = "127.0.0.1";
5227
5670
  var SERVE_SERVICE_NAME = "hooks-registry";
5671
+ function packageVersion() {
5672
+ try {
5673
+ const pkgPath = join7(dirname3(fileURLToPath2(import.meta.url)), "..", "package.json");
5674
+ return JSON.parse(readFileSync5(pkgPath, "utf-8")).version || "0.0.0";
5675
+ } catch {
5676
+ return "0.0.0";
5677
+ }
5678
+ }
5228
5679
  async function buildCatalog() {
5229
5680
  const byName = new Map;
5230
5681
  for (const meta of HOOKS) {
@@ -5297,6 +5748,20 @@ function handleServeRequest(req, apiKey) {
5297
5748
  if (url.pathname === "/health" && req.method === "GET") {
5298
5749
  return Promise.resolve(json({ status: "ok", name: SERVE_SERVICE_NAME }));
5299
5750
  }
5751
+ if (url.pathname === "/ready" && req.method === "GET") {
5752
+ try {
5753
+ readLock();
5754
+ return Promise.resolve(json({ status: "ready", name: SERVE_SERVICE_NAME }));
5755
+ } catch {
5756
+ return Promise.resolve(json({ status: "not-ready", name: SERVE_SERVICE_NAME }, 503));
5757
+ }
5758
+ }
5759
+ if (url.pathname === "/version" && req.method === "GET") {
5760
+ return Promise.resolve(json({ version: packageVersion() }));
5761
+ }
5762
+ if (url.pathname === "/openapi.json" && req.method === "GET") {
5763
+ return Promise.resolve(json(openApiDocument));
5764
+ }
5300
5765
  if (url.pathname === "/api/v1/catalog" && req.method === "GET") {
5301
5766
  return buildCatalog().then((catalog) => json({ hooks: catalog }));
5302
5767
  }