@methiu/raigal 0.2.0 → 0.2.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.
package/dist/index.js CHANGED
@@ -1,4 +1,4 @@
1
- import { t as APP_VERSION } from "./version-v4OAaBLZ.js";
1
+ import { t as APP_VERSION } from "./version-9Cnt1TiK.js";
2
2
  import { a as getEngineLabel, i as ENGINE_INFO, r as scoreImpactForRule, t as summarizeFindingAssessments } from "./finding-assessment-M5fUTE-C.js";
3
3
  import { n as isSecretClassRule, r as maskSecrets, t as FIXED_SECRET_DIAGNOSTIC_MESSAGE } from "./mask-secrets-BXn-ZSEh.js";
4
4
  import { createRequire, isBuiltin } from "node:module";
@@ -8,14 +8,14 @@ import YAML, { parse } from "yaml";
8
8
  import { z } from "zod/v4";
9
9
  import pc from "picocolors";
10
10
  import wcwidth from "wcwidth";
11
- import micromatch from "micromatch";
11
+ import picomatch from "picomatch";
12
12
  import { spawn, spawnSync } from "node:child_process";
13
13
  import { fileURLToPath, pathToFileURL } from "node:url";
14
14
  import os, { tmpdir } from "node:os";
15
15
  import { performance } from "node:perf_hooks";
16
16
  import ts from "typescript";
17
17
  import { parse as parse$1 } from "smol-toml";
18
- import { createHash, createPublicKey, randomUUID, verify } from "node:crypto";
18
+ import crypto, { createHash, createPublicKey, randomUUID, verify } from "node:crypto";
19
19
  import { isCancel, multiselect, select, text } from "@clack/prompts";
20
20
  import * as readline from "node:readline";
21
21
  import { Writable } from "node:stream";
@@ -88,7 +88,8 @@ const DEFAULT_CONFIG = {
88
88
  format: "json"
89
89
  },
90
90
  telemetry: { enabled: true },
91
- rules: {}
91
+ rules: {},
92
+ overrides: []
92
93
  };
93
94
  const GITHUB_WORKFLOW_DIR = ".github/workflows";
94
95
  const GITHUB_WORKFLOW_FILE = "raigal.yml";
@@ -318,6 +319,16 @@ const RuleSeverityOverride = z.enum([
318
319
  "off"
319
320
  ]);
320
321
  const RulesSchema = z.record(z.string(), RuleSeverityOverride).default(() => ({}));
322
+ const FileOverrideSchema = z.object({
323
+ files: z.array(z.string().min(1)).min(1),
324
+ quality: z.object({
325
+ maxFunctionLoc: QualitySchema.shape.maxFunctionLoc.unwrap().optional(),
326
+ maxFileLoc: QualitySchema.shape.maxFileLoc.unwrap().optional(),
327
+ maxNesting: QualitySchema.shape.maxNesting.unwrap().optional(),
328
+ maxParams: QualitySchema.shape.maxParams.unwrap().optional()
329
+ }).optional(),
330
+ rules: z.record(z.string(), RuleSeverityOverride).optional()
331
+ });
321
332
  const AislopConfigSchema = z.object({
322
333
  version: z.number().default(1),
323
334
  extends: z.union([z.string(), z.array(z.string())]).optional(),
@@ -373,6 +384,7 @@ const AislopConfigSchema = z.object({
373
384
  })),
374
385
  telemetry: TelemetrySchema.default(() => ({ enabled: true })),
375
386
  rules: RulesSchema,
387
+ overrides: z.array(FileOverrideSchema).default(() => []),
376
388
  exclude: z.array(z.string()).default(() => [
377
389
  "node_modules",
378
390
  ".git",
@@ -674,7 +686,7 @@ const highlightRaigal = (text, theme, baseToken) => {
674
686
  const tail = text.slice(lastIndex);
675
687
  return out + (baseToken ? style(theme, baseToken, tail) : tail);
676
688
  };
677
- const highlightAislop = highlightRaigal;
689
+ const highlightAislop = (...args) => highlightRaigal(...args);
678
690
  //#endregion
679
691
  //#region src/ui/width.ts
680
692
  const ANSI_RE$1 = new RegExp(`\\[[0-9;]*m`, "g");
@@ -938,13 +950,13 @@ const normalizeExcludePatterns = (patterns) => patterns.flatMap((pattern) => {
938
950
  const normalized = withoutProjectPrefix.slice(0, end);
939
951
  if (normalized.length === 0) return [];
940
952
  if (normalized.length > 256) return [];
941
- if (micromatch.scan(normalized).isGlob) return [normalized];
953
+ if (picomatch.scan(normalized).isGlob) return [normalized];
942
954
  if (normalized.startsWith(".") && !normalized.includes("/")) return supportedGlobPatterns([`**/${normalized}`, `**/${normalized}/**`]);
943
955
  return supportedGlobPatterns([normalized, `${normalized}/**`]);
944
956
  });
945
957
  const isPathExcluded = (relativePath, normalizedPatterns) => {
946
958
  if (normalizedPatterns.length === 0) return false;
947
- return micromatch.isMatch(relativePath, normalizedPatterns, { dot: true });
959
+ return picomatch.isMatch(relativePath, normalizedPatterns, { dot: true });
948
960
  };
949
961
  const filterExcludedDiagnostics = (diagnostics, rootDirectory, excludePatterns) => {
950
962
  if (!excludePatterns || excludePatterns.length === 0) return diagnostics;
@@ -1312,12 +1324,13 @@ const normalizeIncludePatterns = (patterns) => patterns.flatMap((pattern) => {
1312
1324
  const normalized = pattern.trim().replace(/^\.\//, "").replace(/\/$/, "");
1313
1325
  if (normalized === "" || normalized === ".") return ["**"];
1314
1326
  if (normalized.length > 256) return [];
1315
- if (micromatch.scan(normalized).isGlob) return [normalized];
1327
+ if (picomatch.scan(normalized).isGlob) return [normalized];
1316
1328
  return supportedGlobPatterns([normalized, `${normalized}/**`]);
1317
1329
  });
1318
1330
  const createPathMatcher = (patterns) => {
1319
- const matchers = patterns.map((pattern) => micromatch.matcher(pattern, { dot: true }));
1320
- return (filePath) => matchers.some((matches) => matches(filePath));
1331
+ if (patterns.length === 0) return () => false;
1332
+ const isMatch = picomatch(patterns, { dot: true });
1333
+ return (filePath) => isMatch(filePath);
1321
1334
  };
1322
1335
  const filterFiles = (rootDirectory, files, options) => {
1323
1336
  const extraExtensions = new Set(options.extraExtensions);
@@ -1800,7 +1813,7 @@ const isRootBoundedTarget = (candidate, rootDirectory) => {
1800
1813
  };
1801
1814
  //#endregion
1802
1815
  //#region src/utils/aislop-ignore.ts
1803
- const readRaigalIgnorePatterns = (rootDirectory) => {
1816
+ const readAislopIgnorePatterns = (rootDirectory) => {
1804
1817
  const patterns = [];
1805
1818
  for (const fileName of [".raigalignore", ".aislopignore"]) {
1806
1819
  const targetPath = safeProjectFilePath(path.join(rootDirectory, fileName), rootDirectory);
@@ -1811,7 +1824,6 @@ const readRaigalIgnorePatterns = (rootDirectory) => {
1811
1824
  }
1812
1825
  return patterns;
1813
1826
  };
1814
- const readAislopIgnorePatterns = readRaigalIgnorePatterns;
1815
1827
  //#endregion
1816
1828
  //#region src/utils/source-file-context.ts
1817
1829
  const getSourceFilesForRoot = (rootDirectory) => filterProjectFiles(rootDirectory, listProjectFiles(rootDirectory));
@@ -2639,6 +2651,132 @@ const doctorCommand = async (directory, options = {}) => {
2639
2651
  }));
2640
2652
  };
2641
2653
  //#endregion
2654
+ //#region src/utils/globs.ts
2655
+ const MAX_BRACE_EXPANSIONS = 256;
2656
+ const braceListAt = (pattern, start) => {
2657
+ let depth = 0;
2658
+ const commas = [];
2659
+ for (let i = start; i < pattern.length; i += 1) {
2660
+ const char = pattern[i];
2661
+ if (char === "\\") i += 1;
2662
+ else if (char === "{") depth += 1;
2663
+ else if (char === ",") {
2664
+ if (depth === 1) commas.push(i);
2665
+ } else if (char === "}") {
2666
+ depth -= 1;
2667
+ if (depth === 0) return commas.length > 0 ? {
2668
+ start,
2669
+ end: i,
2670
+ commas
2671
+ } : null;
2672
+ }
2673
+ }
2674
+ return null;
2675
+ };
2676
+ const findBraceList = (pattern) => {
2677
+ for (let start = pattern.indexOf("{"); start !== -1; start = pattern.indexOf("{", start + 1)) {
2678
+ if (start > 0 && pattern[start - 1] === "\\") continue;
2679
+ const list = braceListAt(pattern, start);
2680
+ if (list) return list;
2681
+ }
2682
+ return null;
2683
+ };
2684
+ const expandInto = (pattern, results) => {
2685
+ const list = findBraceList(pattern);
2686
+ if (!list) {
2687
+ results.push(pattern);
2688
+ return results.length <= MAX_BRACE_EXPANSIONS;
2689
+ }
2690
+ const bounds = [
2691
+ list.start,
2692
+ ...list.commas,
2693
+ list.end
2694
+ ];
2695
+ const prefix = pattern.slice(0, list.start);
2696
+ const suffix = pattern.slice(list.end + 1);
2697
+ for (let i = 0; i < bounds.length - 1; i += 1) {
2698
+ const option = pattern.slice(bounds[i] + 1, bounds[i + 1]);
2699
+ if (!expandInto(`${prefix}${option}${suffix}`, results)) return false;
2700
+ }
2701
+ return true;
2702
+ };
2703
+ const expandBraceLists = (pattern) => {
2704
+ const results = [];
2705
+ return expandInto(pattern, results) ? results : [pattern];
2706
+ };
2707
+ const isNegated = (pattern) => picomatch.scan(pattern).negated;
2708
+ const matchesGlobList = (value, patterns, options = {}) => {
2709
+ const expanded = patterns.flatMap(expandBraceLists);
2710
+ const positives = expanded.filter((pattern) => !isNegated(pattern));
2711
+ const negatives = expanded.filter(isNegated).map((pattern) => pattern.slice(1));
2712
+ const included = positives.length === 0 || picomatch.isMatch(value, positives, options);
2713
+ const excluded = negatives.length > 0 && picomatch.isMatch(value, negatives, options);
2714
+ return included && !excluded;
2715
+ };
2716
+ //#endregion
2717
+ //#region src/scoring/rule-severity.ts
2718
+ /**
2719
+ * Apply per-rule severity overrides from config: "off" drops the diagnostic,
2720
+ * "error"/"warning" rewrite its severity before scoring and rendering.
2721
+ */
2722
+ const applyRuleSeverities = (diagnostics, overrides) => {
2723
+ if (Object.keys(overrides).length === 0) return diagnostics;
2724
+ const result = [];
2725
+ for (const diagnostic of diagnostics) {
2726
+ const override = overrides[diagnostic.rule];
2727
+ if (!override) {
2728
+ result.push(diagnostic);
2729
+ continue;
2730
+ }
2731
+ if (override === "off") continue;
2732
+ result.push(override === diagnostic.severity ? diagnostic : {
2733
+ ...diagnostic,
2734
+ severity: override
2735
+ });
2736
+ }
2737
+ return result;
2738
+ };
2739
+ //#endregion
2740
+ //#region src/config/overrides.ts
2741
+ const overrideRelativePath = (rootDirectory, filePath) => {
2742
+ const paths = path.win32.isAbsolute(rootDirectory) && !path.posix.isAbsolute(rootDirectory) ? path.win32 : path.posix;
2743
+ const root = rootDirectory.replaceAll("\\", "/");
2744
+ const file = filePath.replaceAll("\\", "/");
2745
+ return paths.normalize(paths.isAbsolute(file) ? paths.relative(root, file) : file).replaceAll("\\", "/");
2746
+ };
2747
+ const matchesEntry = (relativePath, patterns) => matchesGlobList(relativePath, patterns, { dot: true });
2748
+ const resolveFilePolicy = (config, relativePath) => {
2749
+ let quality = config.quality;
2750
+ let rules = config.rules ?? {};
2751
+ if (relativePath === ".." || relativePath.startsWith("../") || path.win32.isAbsolute(relativePath)) return {
2752
+ quality,
2753
+ rules
2754
+ };
2755
+ for (const entry of config.overrides ?? []) {
2756
+ if (!matchesEntry(relativePath, entry.files)) continue;
2757
+ if (entry.quality) quality = {
2758
+ ...quality,
2759
+ ...entry.quality
2760
+ };
2761
+ if (entry.rules) rules = {
2762
+ ...rules,
2763
+ ...entry.rules
2764
+ };
2765
+ }
2766
+ return {
2767
+ quality,
2768
+ rules
2769
+ };
2770
+ };
2771
+ const applyFileOverrides = (diagnostics, context) => {
2772
+ if (!context.config.overrides?.length) return diagnostics;
2773
+ return diagnostics.flatMap((diagnostic) => {
2774
+ const relativePath = overrideRelativePath(context.rootDirectory, diagnostic.filePath);
2775
+ const { rules } = resolveFilePolicy(context.config, relativePath);
2776
+ return applyRuleSeverities([diagnostic], rules);
2777
+ });
2778
+ };
2779
+ //#endregion
2642
2780
  //#region src/utils/string-literals.ts
2643
2781
  const consumeQuotedString = (content, start, quote) => {
2644
2782
  const len = content.length;
@@ -10381,10 +10519,10 @@ const checkFileComplexity = (filePath, rootDirectory, limits) => {
10381
10519
  };
10382
10520
  const checkComplexity = async (context) => {
10383
10521
  const files = getSourceFiles(context);
10384
- const limits = context.config.quality;
10385
10522
  const diagnostics = [];
10386
10523
  for (const filePath of files) {
10387
10524
  if (isAutoGenerated(filePath)) continue;
10525
+ const { quality: limits } = resolveFilePolicy(context.config, overrideRelativePath(context.rootDirectory, filePath));
10388
10526
  diagnostics.push(...checkFileComplexity(filePath, context.rootDirectory, limits));
10389
10527
  }
10390
10528
  return diagnostics;
@@ -11276,13 +11414,23 @@ const withReportPath = async (run) => {
11276
11414
  };
11277
11415
  const UNSUPPORTED_GLOB_SYNTAX = /[?[\]{}!|()\\]/;
11278
11416
  const isDotnetFormatPath = (pattern) => !pattern.startsWith("-") && !UNSUPPORTED_GLOB_SYNTAX.test(pattern);
11417
+ const expandBraces = (pattern, depth = 0) => {
11418
+ if (depth > 10) return [pattern];
11419
+ const match = /\{([^{}]+)\}/.exec(pattern);
11420
+ if (!match) return [pattern];
11421
+ const [fullMatch, options] = match;
11422
+ const parts = options.split(",");
11423
+ const prefix = pattern.slice(0, match.index);
11424
+ const suffix = pattern.slice(match.index + fullMatch.length);
11425
+ return parts.flatMap((part) => expandBraces(`${prefix}${part}${suffix}`, depth + 1));
11426
+ };
11279
11427
  const buildDotnetFormatExcludeScope = (excludePatterns) => {
11280
11428
  const scope = {
11281
11429
  excludeArguments: [],
11282
11430
  unsupportedPatterns: []
11283
11431
  };
11284
11432
  for (const pattern of normalizeExcludePatterns(excludePatterns ?? [])) {
11285
- const expanded = micromatch.braces(pattern, { expand: true });
11433
+ const expanded = expandBraces(pattern);
11286
11434
  if (expanded.every(isDotnetFormatPath)) scope.excludeArguments.push(...expanded);
11287
11435
  else scope.unsupportedPatterns.push(pattern);
11288
11436
  }
@@ -13076,7 +13224,7 @@ const normalizeEslintIgnorePatterns = (patterns) => patterns.map((pattern) => pa
13076
13224
  if (!pattern.includes("*") && !path.extname(pattern)) return [`${pattern}/**`];
13077
13225
  return [pattern];
13078
13226
  });
13079
- const isEslintIgnored = (relativePath, patterns) => patterns.length > 0 && micromatch.isMatch(relativePath, patterns, { dot: true });
13227
+ const isEslintIgnored = (relativePath, patterns) => patterns.length > 0 && picomatch.isMatch(relativePath, patterns, { dot: true });
13080
13228
  const getOxlintTargets = (context) => {
13081
13229
  const eslintIgnorePatterns = normalizeEslintIgnorePatterns(collectEslintIgnorePatterns(context.rootDirectory));
13082
13230
  return getSourceFiles(context).filter((filePath) => OXLINT_EXTENSIONS.has(path.extname(filePath).toLowerCase())).filter((filePath) => !isAutoGenerated(filePath)).map((filePath) => path.relative(context.rootDirectory, filePath).split(path.sep).join("/")).filter((filePath) => !isEslintIgnored(filePath, eslintIgnorePatterns));
@@ -13897,7 +14045,8 @@ const readRuntimeDependencies = (rootDir) => {
13897
14045
  collectRuntimeNames(rootManifest, names);
13898
14046
  const globs = [...npmWorkspaceGlobs(rootManifest), ...pnpmWorkspaceGlobs(rootDir)];
13899
14047
  if (globs.length === 0) return { runtimeDependencies: names };
13900
- for (const relativeDir of micromatch(listPackageDirectories(rootDir), globs)) {
14048
+ const isMatch = picomatch(globs, { dot: true });
14049
+ for (const relativeDir of listPackageDirectories(rootDir).filter((dir) => isMatch(dir))) {
13901
14050
  const memberManifest = readJsonFile(path.join(rootDir, relativeDir, "package.json"));
13902
14051
  if (memberManifest) collectRuntimeNames(memberManifest, names);
13903
14052
  }
@@ -14693,6 +14842,7 @@ const runEngines = async (context, enabledEngines, onStart, onComplete) => {
14693
14842
  const result = await engine.run(context);
14694
14843
  result.diagnostics = filterExcludedDiagnostics(result.diagnostics, context.rootDirectory, context.excludePatterns);
14695
14844
  result.elapsed = performance.now() - start;
14845
+ result.diagnostics = applyFileOverrides(result.diagnostics, context);
14696
14846
  onComplete?.(result);
14697
14847
  return result;
14698
14848
  } catch (error) {
@@ -14701,6 +14851,7 @@ const runEngines = async (context, enabledEngines, onStart, onComplete) => {
14701
14851
  diagnostics: [],
14702
14852
  elapsed: performance.now() - start,
14703
14853
  skipped: true,
14854
+ failed: true,
14704
14855
  skipReason: error instanceof Error ? error.message : String(error)
14705
14856
  };
14706
14857
  onComplete?.(result);
@@ -14711,6 +14862,7 @@ const runEngines = async (context, enabledEngines, onStart, onComplete) => {
14711
14862
  diagnostics: [],
14712
14863
  elapsed: 0,
14713
14864
  skipped: true,
14865
+ failed: true,
14714
14866
  skipReason: r.reason instanceof Error ? r.reason.message : String(r.reason)
14715
14867
  });
14716
14868
  return dedupeEquivalenceDiagnostics(dedupeOverlappingComments(dedupeCSharpAsync(stripSecretDiagnostics(finalResults))));
@@ -14929,6 +15081,10 @@ const SAFE_PROPERTY_NAMES = /* @__PURE__ */ new Set([
14929
15081
  "exit_code",
14930
15082
  "duration_ms",
14931
15083
  "error_kind",
15084
+ "error_name",
15085
+ "error_code",
15086
+ "failed_stage",
15087
+ "engines_failed",
14932
15088
  "score",
14933
15089
  "score_bucket",
14934
15090
  "finding_count",
@@ -15157,6 +15313,8 @@ const buildCommandCompletedProps = (input) => {
15157
15313
  if (typeof input.fixSteps === "number") props.fix_steps = input.fixSteps;
15158
15314
  if (typeof input.fixResolved === "number") props.fix_resolved = input.fixResolved;
15159
15315
  if (typeof input.fixScoreDelta === "number") props.fix_score_delta = input.fixScoreDelta;
15316
+ if (input.errorName) props.error_name = input.errorName;
15317
+ if (input.errorCode) props.error_code = input.errorCode;
15160
15318
  return props;
15161
15319
  };
15162
15320
  const errorKindFromException = (error) => {
@@ -15166,6 +15324,26 @@ const errorKindFromException = (error) => {
15166
15324
  if (message.includes("engine") && message.includes("crash")) return "engine_crash";
15167
15325
  return "unknown";
15168
15326
  };
15327
+ const SAFE_ERROR_NAME = /^[A-Za-z_$][A-Za-z0-9_$]{0,39}$/;
15328
+ const SAFE_ERROR_CODE = /^[A-Za-z][A-Za-z0-9_]{0,39}$/;
15329
+ const errorIdentity = (error) => {
15330
+ let name;
15331
+ if (error instanceof Error && typeof error.name === "string" && error.name.length > 0) name = error.name;
15332
+ else if (error === null) name = "null";
15333
+ else if (typeof error === "object") name = "object";
15334
+ else name = typeof error;
15335
+ const identity = { error_name: SAFE_ERROR_NAME.test(name) ? name : "Error" };
15336
+ const code = error?.code;
15337
+ if (typeof code === "string" && SAFE_ERROR_CODE.test(code)) identity.error_code = code;
15338
+ else if (typeof code === "number" && Number.isFinite(code)) identity.error_code = String(code);
15339
+ return identity;
15340
+ };
15341
+ //#endregion
15342
+ //#region src/telemetry/fatal.ts
15343
+ const reportedErrors = /* @__PURE__ */ new Set();
15344
+ const markErrorReported = (error) => {
15345
+ reportedErrors.add(error);
15346
+ };
15169
15347
  //#endregion
15170
15348
  //#region src/telemetry/lifecycle.ts
15171
15349
  const STARTED_FLUSH_TIMEOUT_MS = 100;
@@ -15176,6 +15354,7 @@ const withCommandLifecycle = async (start, run) => {
15176
15354
  fileCount: start.fileCount,
15177
15355
  properties: start.properties
15178
15356
  });
15357
+ start.config;
15179
15358
  track({
15180
15359
  event: "cli_command_started",
15181
15360
  properties: startProps,
@@ -15192,6 +15371,7 @@ const withCommandLifecycle = async (start, run) => {
15192
15371
  startProps,
15193
15372
  exitCode: result.exitCode,
15194
15373
  durationMs,
15374
+ errorKind: result.errorKind,
15195
15375
  score: result.score ?? void 0,
15196
15376
  findingCount: result.findingCount,
15197
15377
  errorCount: result.errorCount,
@@ -15209,16 +15389,21 @@ const withCommandLifecycle = async (start, run) => {
15209
15389
  await flushTelemetry();
15210
15390
  return result;
15211
15391
  } catch (error) {
15392
+ const durationMs = performance.now() - startedAt;
15393
+ const identity = errorIdentity(error);
15212
15394
  track({
15213
15395
  event: "cli_command_completed",
15214
15396
  properties: buildCommandCompletedProps({
15215
15397
  startProps,
15216
15398
  exitCode: 1,
15217
- durationMs: performance.now() - startedAt,
15218
- errorKind: errorKindFromException(error)
15399
+ durationMs,
15400
+ errorKind: errorKindFromException(error),
15401
+ errorName: identity.error_name,
15402
+ errorCode: identity.error_code
15219
15403
  }),
15220
15404
  config: start.config
15221
15405
  });
15406
+ markErrorReported(error);
15222
15407
  await flushTelemetry();
15223
15408
  throw error;
15224
15409
  }
@@ -15714,7 +15899,9 @@ const createEngineContext = (rootDirectory, projectInfo, config, options = {}) =
15714
15899
  quality: config.quality,
15715
15900
  security: config.security,
15716
15901
  lint: config.lint,
15717
- engines: config.engines
15902
+ engines: config.engines,
15903
+ overrides: config.overrides,
15904
+ rules: config.rules
15718
15905
  },
15719
15906
  ...options.scope ? {
15720
15907
  files: options.scope.files,
@@ -16068,7 +16255,7 @@ const verifyEntitlementJwt = (jwt, options = {}) => {
16068
16255
  const claims = parsed.data;
16069
16256
  const expectedIssuer = process.env.RAIGAL_ISSUER?.trim() || "https://app.raigal.dev";
16070
16257
  if (claims.iss !== expectedIssuer) throw new JwtVerificationError(`Untrusted issuer: ${claims.iss}`, "invalid_issuer");
16071
- if (!isVersionAtLeast("0.2.0", claims.min_cli_version)) throw new JwtVerificationError(`CLI version ${APP_VERSION} is below required minimum ${claims.min_cli_version}. Run "raigal update" or "npm install -g @methiu/raigal@latest".`, "cli_version_unsupported");
16258
+ if (!isVersionAtLeast("0.2.2", claims.min_cli_version)) throw new JwtVerificationError(`CLI version ${APP_VERSION} is below required minimum ${claims.min_cli_version}. Run "raigal update" or "npm install -g @methiu/raigal@latest".`, "cli_version_unsupported");
16072
16259
  const now = options.nowSeconds ?? Math.floor(Date.now() / 1e3);
16073
16260
  const grace = options.allowGraceSeconds ?? 0;
16074
16261
  if (now > claims.exp + grace) {
@@ -16182,6 +16369,10 @@ const handleEntitlementFetchError = (err, cached, cachedClaims, now) => {
16182
16369
  nowSeconds: now,
16183
16370
  allowGraceSeconds: GRACE_PERIOD_SECONDS
16184
16371
  });
16372
+ if (graceClaims.plan === "trial" && graceClaims.trial_ends_at && now > Math.floor(new Date(graceClaims.trial_ends_at).getTime() / 1e3)) {
16373
+ clearCachedEntitlement();
16374
+ throw new LicenceDeniedError("Organization trial has expired", "trial_expired");
16375
+ }
16185
16376
  return {
16186
16377
  jwt: cached.entitlement,
16187
16378
  claims: graceClaims,
@@ -16189,6 +16380,7 @@ const handleEntitlementFetchError = (err, cached, cachedClaims, now) => {
16189
16380
  grace: now > graceClaims.exp
16190
16381
  };
16191
16382
  } catch (jwtErr) {
16383
+ if (jwtErr instanceof LicenceDeniedError) throw jwtErr;
16192
16384
  if (jwtErr instanceof JwtVerificationError && jwtErr.code === "expired_beyond_grace") throw new LicenceUnverifiableError(`Raigal license could not be verified online and the offline grace period expired on ${(/* @__PURE__ */ new Date((cachedClaims?.exp ?? 0) * 1e3)).toISOString()}. Check network connectivity or Raigal Cloud status.`);
16193
16385
  }
16194
16386
  throw new LicenceUnverifiableError(`Raigal license could not be verified online (${err instanceof Error ? err.message : String(err)}). Check network connectivity or Raigal Cloud status.`);
@@ -16203,7 +16395,8 @@ const getEntitlement = async () => {
16203
16395
  } catch {
16204
16396
  cachedClaims = null;
16205
16397
  }
16206
- if (cached && cachedClaims && now < cached.refresh_after) return {
16398
+ const isTrialExpired = cachedClaims?.plan === "trial" && cachedClaims.trial_ends_at && now > Math.floor(new Date(cachedClaims.trial_ends_at).getTime() / 1e3);
16399
+ if (cached && cachedClaims && !isTrialExpired && now < cached.refresh_after) return {
16207
16400
  jwt: cached.entitlement,
16208
16401
  claims: cachedClaims,
16209
16402
  fromCache: true
@@ -16212,7 +16405,7 @@ const getEntitlement = async () => {
16212
16405
  if (!activeToken) {
16213
16406
  oidcToken = await fetchGitHubOidcToken();
16214
16407
  if (!oidcToken) {
16215
- if (cached && cachedClaims) return {
16408
+ if (cached && cachedClaims && !isTrialExpired) return {
16216
16409
  jwt: cached.entitlement,
16217
16410
  claims: cachedClaims,
16218
16411
  fromCache: true
@@ -16373,6 +16566,23 @@ const requireEntitlement = async (options = {}) => {
16373
16566
  try {
16374
16567
  const entitlement = await getEntitlement();
16375
16568
  const tokenKind = getActiveToken()?.kind ?? "session";
16569
+ if (entitlement.claims.plan === "trial" && entitlement.claims.trial_ends_at) {
16570
+ const trialEnd = new Date(entitlement.claims.trial_ends_at).getTime();
16571
+ if (Date.now() > trialEnd) {
16572
+ const message = "Raigal license denied: Organization trial has expired";
16573
+ if (mode === "throw") throw new LicenceDeniedError(message, "trial_expired");
16574
+ if (mode === "fatal") {
16575
+ process.stderr.write(`${message}\n`);
16576
+ process.exit(30);
16577
+ }
16578
+ process.stderr.write(`[raigal] ${message}\n`);
16579
+ return {
16580
+ ok: false,
16581
+ exitCode: 30,
16582
+ message
16583
+ };
16584
+ }
16585
+ }
16376
16586
  const detectedOwner = detectLocalRepoOwner(directory);
16377
16587
  if (options.repoOwner) detectedOwner.owner = options.repoOwner.toLowerCase();
16378
16588
  const ownerCheck = checkOwnerAgainstAllowlist(detectedOwner, entitlement.claims.allowed_owners, tokenKind);
@@ -16929,6 +17139,56 @@ const computeRemovalRange = (sourceFile, node, content) => {
16929
17139
  end: finalEnd
16930
17140
  };
16931
17141
  };
17142
+ const isReferencedOutside = (sourceFile, statement, name) => {
17143
+ const start = statement.getStart(sourceFile);
17144
+ const end = statement.getEnd();
17145
+ let found = false;
17146
+ const visit = (node) => {
17147
+ if (found) return;
17148
+ if (ts.isIdentifier(node) && node.text === name) {
17149
+ const pos = node.getStart(sourceFile);
17150
+ if (pos < start || pos >= end) found = true;
17151
+ return;
17152
+ }
17153
+ ts.forEachChild(node, visit);
17154
+ };
17155
+ visit(sourceFile);
17156
+ return found;
17157
+ };
17158
+ const exportKeywordRange = (sourceFile, statement, content) => {
17159
+ const modifiers = ts.canHaveModifiers(statement) ? ts.getModifiers(statement) ?? [] : [];
17160
+ if (modifiers.some((m) => m.kind === ts.SyntaxKind.DefaultKeyword)) return null;
17161
+ const exportKeyword = modifiers.find((m) => m.kind === ts.SyntaxKind.ExportKeyword);
17162
+ if (!exportKeyword) return null;
17163
+ let end = exportKeyword.getEnd();
17164
+ while (end < content.length && (content[end] === " " || content[end] === " ")) end++;
17165
+ return {
17166
+ start: exportKeyword.getStart(sourceFile),
17167
+ end
17168
+ };
17169
+ };
17170
+ const removalFor = (sourceFile, statement, content, decl) => {
17171
+ if (!isReferencedOutside(sourceFile, statement, decl.name)) return null;
17172
+ const range = exportKeywordRange(sourceFile, statement, content);
17173
+ if (!range) return {
17174
+ type: "skip",
17175
+ reason: "still referenced in its own file",
17176
+ declaration: decl
17177
+ };
17178
+ return {
17179
+ type: "match",
17180
+ removal: {
17181
+ ...range,
17182
+ declaration: decl
17183
+ }
17184
+ };
17185
+ };
17186
+ const declaredNames = (statement) => {
17187
+ if (ts.isVariableStatement(statement)) return statement.declarationList.declarations.map((d) => ts.isIdentifier(d.name) ? d.name.text : null).filter((name) => name !== null);
17188
+ if (ts.isFunctionDeclaration(statement) || ts.isClassDeclaration(statement) || ts.isInterfaceDeclaration(statement) || ts.isTypeAliasDeclaration(statement) || ts.isEnumDeclaration(statement) || ts.isModuleDeclaration(statement)) return statement.name && ts.isIdentifier(statement.name) ? [statement.name.text] : [];
17189
+ return [];
17190
+ };
17191
+ const isDeclaredMoreThanOnce = (sourceFile, name) => sourceFile.statements.filter((s) => declaredNames(s).includes(name)).length > 1;
16932
17192
  const kindOfStatement = (node) => {
16933
17193
  if (ts.isVariableStatement(node)) return "variable";
16934
17194
  if (ts.isFunctionDeclaration(node)) return "function";
@@ -16940,6 +17200,11 @@ const kindOfStatement = (node) => {
16940
17200
  };
16941
17201
  const matchStatement = (sourceFile, statement, content, decl) => {
16942
17202
  if (!kindOfStatement(statement)) return { type: "none" };
17203
+ if (declaredNames(statement).includes(decl.name) && isDeclaredMoreThanOnce(sourceFile, decl.name)) return {
17204
+ type: "skip",
17205
+ reason: "merged declaration",
17206
+ declaration: decl
17207
+ };
16943
17208
  if (ts.isVariableStatement(statement)) {
16944
17209
  const varDecls = statement.declarationList.declarations;
16945
17210
  if (varDecls.length === 0) return { type: "none" };
@@ -16955,6 +17220,8 @@ const matchStatement = (sourceFile, statement, content, decl) => {
16955
17220
  reason: "multi-declaration variable statement",
16956
17221
  declaration: decl
16957
17222
  };
17223
+ const referenced = removalFor(sourceFile, statement, content, decl);
17224
+ if (referenced) return referenced;
16958
17225
  if (initializerHasSideEffects(match.initializer)) return {
16959
17226
  type: "skip",
16960
17227
  reason: "initializer may have side effects",
@@ -16972,6 +17239,8 @@ const matchStatement = (sourceFile, statement, content, decl) => {
16972
17239
  if (!statement.name) return { type: "none" };
16973
17240
  if (statement.name.text !== decl.name) return { type: "none" };
16974
17241
  if (!nodeContainsLine(sourceFile, statement, decl.line)) return { type: "none" };
17242
+ const referenced = removalFor(sourceFile, statement, content, decl);
17243
+ if (referenced) return referenced;
16975
17244
  return {
16976
17245
  type: "match",
16977
17246
  removal: {
@@ -16983,6 +17252,8 @@ const matchStatement = (sourceFile, statement, content, decl) => {
16983
17252
  if (ts.isTypeAliasDeclaration(statement) || ts.isInterfaceDeclaration(statement) || ts.isEnumDeclaration(statement)) {
16984
17253
  if (statement.name.text !== decl.name) return { type: "none" };
16985
17254
  if (!nodeContainsLine(sourceFile, statement, decl.line)) return { type: "none" };
17255
+ const referenced = removalFor(sourceFile, statement, content, decl);
17256
+ if (referenced) return referenced;
16986
17257
  return {
16987
17258
  type: "match",
16988
17259
  removal: {
@@ -18198,6 +18469,835 @@ const recordFullScanActivity = (history, allowPilotInvitation, telemetryConfig)
18198
18469
  appendHistory(history);
18199
18470
  return allowPilotInvitation && !isHistoryDisabled() && !isTelemetryDisabled(telemetryConfig) && recordCompletedFullScan();
18200
18471
  };
18472
+ const AISLOP_MD_BODY = `# Raigal - agent instructions
18473
+
18474
+ [Raigal](https://github.com/meethiu/raigal) is your guardrail for shipping clean, maintainable, production-grade code. It catches the patterns AI coding tools leave behind: dead imports, redundant code, duplicate logic, unsafe any-typed casts, narrative comments, swallowed errors, hidden fallbacks, oversized functions, risky security constructs - and grades the project out of 100 so regressions are visible. Treat its findings as blocking, not advisory: the goal is code a human maintainer would happily inherit.
18475
+
18476
+ ## On every edit
18477
+
18478
+ A PostToolUse hook runs \`raigal hook claude\` after every Edit, Write, or MultiEdit. It scans the touched files and returns findings as JSON \`additionalContext\` shaped like \`RaigalFeedback\` (score, baseline, delta, regressed, counts, findings, newSinceBaseline, suggestedActions). Act on them the same turn; the \`suggestedActions\` field tells you exactly what to run next.
18479
+
18480
+ ## Severity ladder
18481
+
18482
+ - \`error\` - MUST fix this turn.
18483
+ - \`warning\` + \`fixable: true\` - MUST fix this turn.
18484
+ - \`warning\` + \`fixable: false\` - fix if trivially mechanical, otherwise surface in your reply.
18485
+
18486
+ ## Rules
18487
+
18488
+ - \`.raigal/config.yaml\` (or \`.aislop/config.yaml\`) - thresholds and engine toggles. Treat as authoritative; don't edit without user consent.
18489
+ - \`.raigal/rules.yaml\` (or \`.aislop/rules.yaml\`) - project-specific architecture rules (may be absent). When a finding cites \`architecture/*\`, open this file and follow it.
18490
+ - Custom rules can change between sessions. Trust what the scan returns, not a cached understanding of what the rules are.
18491
+
18492
+ ## Principles
18493
+
18494
+ - Do not disable rules to pass the scan. Fix the underlying issue.
18495
+ - If a finding is a false positive, leave it and explain in your reply - do not delete the rule config.
18496
+ - The findings payload includes \`nextSteps[]\` - treat those as your plan for the turn.
18497
+ `;
18498
+ //#endregion
18499
+ //#region src/hooks/io/atomic-write.ts
18500
+ const atomicWrite = (targetPath, content) => {
18501
+ const dir = path.dirname(targetPath);
18502
+ fs.mkdirSync(dir, { recursive: true });
18503
+ const rand = Math.random().toString(36).slice(2, 10);
18504
+ const tmp = path.join(dir, `.aislop-tmp-${process.pid}-${rand}`);
18505
+ fs.writeFileSync(tmp, content, "utf-8");
18506
+ try {
18507
+ fs.renameSync(tmp, targetPath);
18508
+ } catch (err) {
18509
+ const code = err?.code;
18510
+ if (code === "EPERM" || code === "EBUSY") {
18511
+ fs.copyFileSync(tmp, targetPath);
18512
+ try {
18513
+ fs.unlinkSync(tmp);
18514
+ } catch {}
18515
+ } else throw err;
18516
+ }
18517
+ };
18518
+ const readIfExists = (targetPath) => {
18519
+ try {
18520
+ return fs.readFileSync(targetPath, "utf-8");
18521
+ } catch {
18522
+ return null;
18523
+ }
18524
+ };
18525
+ //#endregion
18526
+ //#region src/hooks/io/sentinel.ts
18527
+ const sentinelHash = (content) => `sha256:${crypto.createHash("sha256").update(content).digest("hex").slice(0, 32)}`;
18528
+ const BEGIN_RE = /<!--\s*aislop:begin\s+v(\d+)(?:\s+hash=([^\s>]+))?\s*-->/;
18529
+ const END_RE = /<!--\s*aislop:end\s+v\d+\s*-->/;
18530
+ const renderFence = (body, hash) => [
18531
+ `<!-- aislop:begin v1 hash=${hash} -->`,
18532
+ body.trimEnd(),
18533
+ "<!-- aislop:end v1 -->"
18534
+ ].join("\n");
18535
+ const upsertMarkdownFence = (existing, body, hash) => {
18536
+ const fenced = renderFence(body, hash);
18537
+ if (existing == null || existing.length === 0) return {
18538
+ nextContent: `${fenced}\n`,
18539
+ replaced: false
18540
+ };
18541
+ const begin = existing.match(BEGIN_RE);
18542
+ const end = existing.match(END_RE);
18543
+ if (begin && end && (end.index ?? 0) > (begin.index ?? 0)) return {
18544
+ nextContent: `${existing.slice(0, begin.index)}${fenced}${existing.slice((end.index ?? 0) + end[0].length)}`,
18545
+ replaced: true
18546
+ };
18547
+ return {
18548
+ nextContent: `${existing}${existing.endsWith("\n") ? "" : "\n"}\n${fenced}\n`,
18549
+ replaced: false
18550
+ };
18551
+ };
18552
+ //#endregion
18553
+ //#region src/hooks/install/types.ts
18554
+ const emptyResult = () => ({
18555
+ wrote: [],
18556
+ skipped: [],
18557
+ planned: []
18558
+ });
18559
+ const applyContent = (result, opts, target, nextContent, summary) => {
18560
+ if (readIfExists(target) === nextContent) {
18561
+ result.skipped.push(target);
18562
+ return;
18563
+ }
18564
+ if (opts.dryRun) {
18565
+ result.planned.push({
18566
+ path: target,
18567
+ summary
18568
+ });
18569
+ return;
18570
+ }
18571
+ atomicWrite(target, nextContent);
18572
+ result.wrote.push(target);
18573
+ };
18574
+ const applyRemoval = (result, opts, target, nextContent) => {
18575
+ const existing = readIfExists(target);
18576
+ if (existing == null) {
18577
+ result.skipped.push(target);
18578
+ return;
18579
+ }
18580
+ if (existing === (nextContent ?? "")) {
18581
+ result.skipped.push(target);
18582
+ return;
18583
+ }
18584
+ if (opts.dryRun) {
18585
+ result.removed.push(target);
18586
+ return;
18587
+ }
18588
+ if (nextContent == null) try {
18589
+ fs.unlinkSync(target);
18590
+ } catch {}
18591
+ else atomicWrite(target, nextContent);
18592
+ result.removed.push(target);
18593
+ };
18594
+ //#endregion
18595
+ //#region src/hooks/install/rules-only.ts
18596
+ const installRulesOnly = (opts, paths, summary) => {
18597
+ const result = emptyResult();
18598
+ const next = upsertMarkdownFence(readIfExists(paths.rules), AISLOP_MD_BODY, sentinelHash(AISLOP_MD_BODY)).nextContent;
18599
+ applyContent(result, opts, paths.rules, next, summary);
18600
+ if (paths.host && paths.marker) {
18601
+ const host = readIfExists(paths.host) ?? "";
18602
+ if (!host.includes(paths.marker)) {
18603
+ const joiner = host.endsWith("\n") || host.length === 0 ? "" : "\n";
18604
+ const prefix = host.length === 0 ? "" : `${host}${joiner}\n`;
18605
+ applyContent(result, opts, paths.host, `${prefix}${paths.marker}\n`, `append ${paths.marker} reference`);
18606
+ } else result.skipped.push(paths.host);
18607
+ }
18608
+ return result;
18609
+ };
18610
+ const uninstallRulesOnly = (opts, paths) => {
18611
+ const result = {
18612
+ removed: [],
18613
+ skipped: []
18614
+ };
18615
+ if (readIfExists(paths.rules) != null) applyRemoval(result, opts, paths.rules, null);
18616
+ else result.skipped.push(paths.rules);
18617
+ if (paths.host && paths.marker) {
18618
+ const host = readIfExists(paths.host);
18619
+ if (host?.includes(paths.marker)) {
18620
+ const stripped = host.split("\n").filter((l) => l.trim() !== paths.marker).join("\n").replace(/\n{3,}/g, "\n\n").trim();
18621
+ applyRemoval(result, opts, paths.host, stripped.length === 0 ? null : `${stripped}\n`);
18622
+ } else result.skipped.push(paths.host);
18623
+ }
18624
+ return result;
18625
+ };
18626
+ //#endregion
18627
+ //#region src/hooks/install/antigravity.ts
18628
+ const resolveAntigravityPaths = (opts) => ({ rules: path.join(opts.cwd, ".agents", "rules", "antigravity-aislop-rules.md") });
18629
+ const installAntigravity = (opts) => {
18630
+ if (opts.scope !== "project") return {
18631
+ wrote: [],
18632
+ skipped: [],
18633
+ planned: [{
18634
+ path: ".agents/rules/antigravity-aislop-rules.md",
18635
+ summary: "Antigravity is project-scope only; pass --project"
18636
+ }]
18637
+ };
18638
+ return installRulesOnly(opts, resolveAntigravityPaths(opts), "write .agents/rules/antigravity-aislop-rules.md");
18639
+ };
18640
+ const uninstallAntigravity = (opts) => {
18641
+ if (opts.scope !== "project") return {
18642
+ removed: [],
18643
+ skipped: []
18644
+ };
18645
+ return uninstallRulesOnly(opts, resolveAntigravityPaths(opts));
18646
+ };
18647
+ //#endregion
18648
+ //#region src/hooks/io/json-patch.ts
18649
+ const AISLOP_SENTINEL_KEY = "__aislop";
18650
+ const isAislopManaged = (x) => typeof x === "object" && x !== null && "__aislop" in x && x["__aislop"] != null;
18651
+ const groupIsAislop = (group) => {
18652
+ if (typeof group !== "object" || group === null) return false;
18653
+ const hooks = group.hooks;
18654
+ if (!Array.isArray(hooks)) return false;
18655
+ return hooks.some((h) => isAislopManaged(h));
18656
+ };
18657
+ const upsertHookGroup = (config, event, group) => {
18658
+ const next = { ...config };
18659
+ const hooks = next.hooks && typeof next.hooks === "object" ? next.hooks : {};
18660
+ const cleaned = (Array.isArray(hooks[event]) ? hooks[event] : []).filter((g) => !groupIsAislop(g));
18661
+ next.hooks = {
18662
+ ...hooks,
18663
+ [event]: [...cleaned, group]
18664
+ };
18665
+ return next;
18666
+ };
18667
+ const upsertFlatHook = (config, event, entry) => {
18668
+ const next = { ...config };
18669
+ const hooks = next.hooks && typeof next.hooks === "object" ? next.hooks : {};
18670
+ const cleaned = (Array.isArray(hooks[event]) ? hooks[event] : []).filter((e) => !isAislopManaged(e));
18671
+ next.hooks = {
18672
+ ...hooks,
18673
+ [event]: [...cleaned, entry]
18674
+ };
18675
+ return next;
18676
+ };
18677
+ const removeAislopEntries = (config, event) => {
18678
+ const next = { ...config };
18679
+ const hooks = next.hooks && typeof next.hooks === "object" ? next.hooks : {};
18680
+ const existing = Array.isArray(hooks[event]) ? hooks[event] : [];
18681
+ const cleaned = existing.filter((e) => !isAislopManaged(e) && !groupIsAislop(e));
18682
+ const removed = existing.length - cleaned.length;
18683
+ const nextHooks = { ...hooks };
18684
+ if (cleaned.length === 0) delete nextHooks[event];
18685
+ else nextHooks[event] = cleaned;
18686
+ if (Object.keys(nextHooks).length === 0) delete next.hooks;
18687
+ else next.hooks = nextHooks;
18688
+ return {
18689
+ next,
18690
+ removed
18691
+ };
18692
+ };
18693
+ //#endregion
18694
+ //#region src/hooks/install/claude.ts
18695
+ const resolveClaudePaths = (opts) => {
18696
+ const root = opts.scope === "project" ? path.join(opts.cwd, ".claude") : path.join(opts.home, ".claude");
18697
+ return {
18698
+ settings: path.join(root, "settings.json"),
18699
+ aislopMd: path.join(root, "AISLOP.md"),
18700
+ claudeMd: path.join(root, "CLAUDE.md")
18701
+ };
18702
+ };
18703
+ const buildHookGroup$1 = () => {
18704
+ const hashBody = JSON.stringify({
18705
+ command: "aislop hook claude",
18706
+ matcher: "Edit|Write|MultiEdit"
18707
+ });
18708
+ return {
18709
+ matcher: "Edit|Write|MultiEdit",
18710
+ hooks: [{
18711
+ type: "command",
18712
+ command: "aislop hook claude",
18713
+ [AISLOP_SENTINEL_KEY]: {
18714
+ v: 1,
18715
+ managed: true,
18716
+ hash: sentinelHash(hashBody)
18717
+ }
18718
+ }]
18719
+ };
18720
+ };
18721
+ const buildStopHookGroup = () => {
18722
+ const hashBody = JSON.stringify({ command: "aislop hook claude --stop" });
18723
+ return {
18724
+ matcher: "",
18725
+ hooks: [{
18726
+ type: "command",
18727
+ command: "aislop hook claude --stop",
18728
+ [AISLOP_SENTINEL_KEY]: {
18729
+ v: 1,
18730
+ managed: true,
18731
+ hash: sentinelHash(hashBody)
18732
+ }
18733
+ }]
18734
+ };
18735
+ };
18736
+ const FILE_CHANGED_MATCHER = ".raigal/config.yml|.raigal/rules.yml|.aislop/config.yml|.aislop/rules.yml|package.json";
18737
+ const buildFileChangedHookGroup = () => {
18738
+ const hashBody = JSON.stringify({
18739
+ command: "aislop hook claude --on-file-changed",
18740
+ matcher: FILE_CHANGED_MATCHER
18741
+ });
18742
+ return {
18743
+ matcher: FILE_CHANGED_MATCHER,
18744
+ hooks: [{
18745
+ type: "command",
18746
+ command: "aislop hook claude --on-file-changed",
18747
+ [AISLOP_SENTINEL_KEY]: {
18748
+ v: 1,
18749
+ managed: true,
18750
+ hash: sentinelHash(hashBody)
18751
+ }
18752
+ }]
18753
+ };
18754
+ };
18755
+ const renderSettings$1 = (existingRaw, qualityGate) => {
18756
+ let obj = {};
18757
+ if (existingRaw) try {
18758
+ obj = JSON.parse(existingRaw);
18759
+ } catch {
18760
+ obj = {};
18761
+ }
18762
+ let next = upsertHookGroup(obj, "PostToolUse", buildHookGroup$1());
18763
+ next = upsertHookGroup(next, "FileChanged", buildFileChangedHookGroup());
18764
+ if (qualityGate) next = upsertHookGroup(next, "Stop", buildStopHookGroup());
18765
+ else next = removeAislopEntries(next, "Stop").next;
18766
+ return `${JSON.stringify(next, null, 2)}\n`;
18767
+ };
18768
+ const installClaude = (opts) => {
18769
+ const paths = resolveClaudePaths(opts);
18770
+ const result = emptyResult();
18771
+ const nextSettings = renderSettings$1(readIfExists(paths.settings), Boolean(opts.qualityGate));
18772
+ applyContent(result, opts, paths.settings, nextSettings, "register PostToolUse + FileChanged hooks");
18773
+ const mdHash = sentinelHash(AISLOP_MD_BODY);
18774
+ const fenced = upsertMarkdownFence(readIfExists(paths.aislopMd), AISLOP_MD_BODY, mdHash);
18775
+ applyContent(result, opts, paths.aislopMd, fenced.nextContent, "write AISLOP.md rules");
18776
+ const existingClaudeMd = readIfExists(paths.claudeMd) ?? "";
18777
+ const marker = "@AISLOP.md";
18778
+ if (!existingClaudeMd.includes(marker)) {
18779
+ const joiner = existingClaudeMd.endsWith("\n") || existingClaudeMd.length === 0 ? "" : "\n";
18780
+ const prefix = existingClaudeMd.length === 0 ? "" : `${existingClaudeMd}${joiner}\n`;
18781
+ applyContent(result, opts, paths.claudeMd, `${prefix}${marker}\n`, "append @AISLOP.md reference");
18782
+ } else result.skipped.push(paths.claudeMd);
18783
+ return result;
18784
+ };
18785
+ const uninstallClaude = (opts) => {
18786
+ const paths = resolveClaudePaths({
18787
+ ...opts,
18788
+ qualityGate: false
18789
+ });
18790
+ const result = {
18791
+ removed: [],
18792
+ skipped: []
18793
+ };
18794
+ const settingsRaw = readIfExists(paths.settings);
18795
+ if (settingsRaw) {
18796
+ let obj = {};
18797
+ try {
18798
+ obj = JSON.parse(settingsRaw);
18799
+ } catch {
18800
+ obj = {};
18801
+ }
18802
+ const afterPostToolUse = removeAislopEntries(obj, "PostToolUse").next;
18803
+ const afterFileChanged = removeAislopEntries(afterPostToolUse, "FileChanged").next;
18804
+ const stripped = removeAislopEntries(afterFileChanged, "Stop").next;
18805
+ const stillHasHooks = stripped.hooks && typeof stripped.hooks === "object" && Object.keys(stripped.hooks).length > 0;
18806
+ const otherKeys = Object.keys(stripped).filter((k) => k !== "hooks");
18807
+ if (!stillHasHooks && otherKeys.length === 0) applyRemoval(result, opts, paths.settings, null);
18808
+ else applyRemoval(result, opts, paths.settings, `${JSON.stringify(stripped, null, 2)}\n`);
18809
+ } else result.skipped.push(paths.settings);
18810
+ if (readIfExists(paths.aislopMd) != null) applyRemoval(result, opts, paths.aislopMd, null);
18811
+ else result.skipped.push(paths.aislopMd);
18812
+ const claudeMd = readIfExists(paths.claudeMd);
18813
+ if (claudeMd?.includes("@AISLOP.md")) {
18814
+ const stripped = claudeMd.split("\n").filter((line) => line.trim() !== "@AISLOP.md").join("\n").replace(/\n{3,}/g, "\n\n").trim();
18815
+ applyRemoval(result, opts, paths.claudeMd, stripped.length === 0 ? null : `${stripped}\n`);
18816
+ } else result.skipped.push(paths.claudeMd);
18817
+ return result;
18818
+ };
18819
+ //#endregion
18820
+ //#region src/hooks/install/cline.ts
18821
+ const resolveClinePaths = (opts) => ({ rules: path.join(opts.cwd, ".clinerules") });
18822
+ const resolveRooPaths = (opts) => ({ rules: path.join(opts.cwd, ".roo", "rules", "aislop.md") });
18823
+ const installCline = (opts) => {
18824
+ if (opts.scope !== "project") return {
18825
+ wrote: [],
18826
+ skipped: [],
18827
+ planned: [{
18828
+ path: ".clinerules",
18829
+ summary: "Cline is project-scope only; pass --project"
18830
+ }]
18831
+ };
18832
+ const cline = installRulesOnly(opts, resolveClinePaths(opts), "write .clinerules");
18833
+ const roo = installRulesOnly(opts, resolveRooPaths(opts), "write .roo/rules/aislop.md");
18834
+ return {
18835
+ wrote: [...cline.wrote, ...roo.wrote],
18836
+ skipped: [...cline.skipped, ...roo.skipped],
18837
+ planned: [...cline.planned, ...roo.planned]
18838
+ };
18839
+ };
18840
+ const uninstallCline = (opts) => {
18841
+ if (opts.scope !== "project") return {
18842
+ removed: [],
18843
+ skipped: []
18844
+ };
18845
+ const a = uninstallRulesOnly(opts, resolveClinePaths(opts));
18846
+ const b = uninstallRulesOnly(opts, resolveRooPaths(opts));
18847
+ return {
18848
+ removed: [...a.removed, ...b.removed],
18849
+ skipped: [...a.skipped, ...b.skipped]
18850
+ };
18851
+ };
18852
+ //#endregion
18853
+ //#region src/hooks/install/codex.ts
18854
+ const resolveCodexPaths = (opts) => ({ rules: opts.scope === "project" ? path.join(opts.cwd, "AGENTS.md") : path.join(opts.home, ".codex", "AGENTS.md") });
18855
+ const installCodex = (opts) => installRulesOnly(opts, resolveCodexPaths(opts), "write AGENTS.md rules for Codex");
18856
+ const uninstallCodex = (opts) => uninstallRulesOnly(opts, resolveCodexPaths(opts));
18857
+ //#endregion
18858
+ //#region src/hooks/install/copilot.ts
18859
+ const resolveCopilotPaths = (opts) => ({ rules: path.join(opts.cwd, ".github", "copilot-instructions.md") });
18860
+ const installCopilot = (opts) => {
18861
+ if (opts.scope !== "project") return {
18862
+ wrote: [],
18863
+ skipped: [],
18864
+ planned: [{
18865
+ path: ".github/copilot-instructions.md",
18866
+ summary: "Copilot is project-scope only; pass --project"
18867
+ }]
18868
+ };
18869
+ return installRulesOnly(opts, resolveCopilotPaths(opts), "write .github/copilot-instructions.md");
18870
+ };
18871
+ const uninstallCopilot = (opts) => {
18872
+ if (opts.scope !== "project") return {
18873
+ removed: [],
18874
+ skipped: []
18875
+ };
18876
+ return uninstallRulesOnly(opts, resolveCopilotPaths(opts));
18877
+ };
18878
+ //#endregion
18879
+ //#region src/hooks/install/cursor.ts
18880
+ const resolveCursorPaths = (opts) => {
18881
+ const root = opts.scope === "project" ? path.join(opts.cwd, ".cursor") : path.join(opts.home, ".cursor");
18882
+ return {
18883
+ hooks: path.join(root, "hooks.json"),
18884
+ rules: path.join(opts.cwd, ".cursor", "rules", "aislop.mdc")
18885
+ };
18886
+ };
18887
+ const buildHookEntry = () => {
18888
+ const hashBody = JSON.stringify({
18889
+ command: "aislop hook cursor",
18890
+ timeout: 5e3
18891
+ });
18892
+ return {
18893
+ command: "aislop hook cursor",
18894
+ type: "command",
18895
+ timeout: 5e3,
18896
+ [AISLOP_SENTINEL_KEY]: {
18897
+ v: 1,
18898
+ managed: true,
18899
+ hash: sentinelHash(hashBody)
18900
+ }
18901
+ };
18902
+ };
18903
+ const renderHooksJson = (existingRaw) => {
18904
+ let obj = { version: 1 };
18905
+ if (existingRaw) try {
18906
+ obj = JSON.parse(existingRaw);
18907
+ } catch {
18908
+ obj = { version: 1 };
18909
+ }
18910
+ if (typeof obj.version !== "number") obj.version = 1;
18911
+ const next = upsertFlatHook(obj, "afterFileEdit", buildHookEntry());
18912
+ return `${JSON.stringify(next, null, 2)}\n`;
18913
+ };
18914
+ const installCursor = (opts) => {
18915
+ const paths = resolveCursorPaths(opts);
18916
+ const result = emptyResult();
18917
+ const nextHooks = renderHooksJson(readIfExists(paths.hooks));
18918
+ applyContent(result, opts, paths.hooks, nextHooks, "register afterFileEdit hook");
18919
+ if (opts.scope === "project") {
18920
+ const rules = upsertMarkdownFence(readIfExists(paths.rules), AISLOP_MD_BODY, sentinelHash(AISLOP_MD_BODY)).nextContent;
18921
+ applyContent(result, opts, paths.rules, rules, "write .cursor/rules/aislop.mdc");
18922
+ }
18923
+ return result;
18924
+ };
18925
+ const uninstallCursor = (opts) => {
18926
+ const paths = resolveCursorPaths(opts);
18927
+ const result = {
18928
+ removed: [],
18929
+ skipped: []
18930
+ };
18931
+ const raw = readIfExists(paths.hooks);
18932
+ if (raw) {
18933
+ let obj = {};
18934
+ try {
18935
+ obj = JSON.parse(raw);
18936
+ } catch {
18937
+ obj = {};
18938
+ }
18939
+ const stripped = removeAislopEntries(obj, "afterFileEdit").next;
18940
+ const stillHasHooks = stripped.hooks && typeof stripped.hooks === "object" && Object.keys(stripped.hooks).length > 0;
18941
+ const otherKeys = Object.keys(stripped).filter((k) => k !== "hooks" && k !== "version");
18942
+ if (!stillHasHooks && otherKeys.length === 0) applyRemoval(result, opts, paths.hooks, null);
18943
+ else applyRemoval(result, opts, paths.hooks, `${JSON.stringify(stripped, null, 2)}\n`);
18944
+ } else result.skipped.push(paths.hooks);
18945
+ if (opts.scope === "project") applyRemoval(result, opts, paths.rules, null);
18946
+ return result;
18947
+ };
18948
+ //#endregion
18949
+ //#region src/hooks/install/gemini.ts
18950
+ const resolveGeminiPaths = (opts) => {
18951
+ const root = opts.scope === "project" ? path.join(opts.cwd, ".gemini") : path.join(opts.home, ".gemini");
18952
+ return {
18953
+ settings: path.join(root, "settings.json"),
18954
+ aislopMd: path.join(root, "AISLOP.md"),
18955
+ geminiMd: path.join(root, "GEMINI.md")
18956
+ };
18957
+ };
18958
+ const buildHookGroup = () => {
18959
+ const hashBody = JSON.stringify({
18960
+ command: "aislop hook gemini",
18961
+ matcher: "write_file|replace"
18962
+ });
18963
+ return {
18964
+ matcher: "write_file|replace",
18965
+ hooks: [{
18966
+ name: "aislop",
18967
+ type: "command",
18968
+ command: "aislop hook gemini",
18969
+ timeout: 5e3,
18970
+ [AISLOP_SENTINEL_KEY]: {
18971
+ v: 1,
18972
+ managed: true,
18973
+ hash: sentinelHash(hashBody)
18974
+ }
18975
+ }]
18976
+ };
18977
+ };
18978
+ const renderSettings = (existingRaw) => {
18979
+ let obj = {};
18980
+ if (existingRaw) try {
18981
+ obj = JSON.parse(existingRaw);
18982
+ } catch {
18983
+ obj = {};
18984
+ }
18985
+ const next = upsertHookGroup(obj, "AfterTool", buildHookGroup());
18986
+ return `${JSON.stringify(next, null, 2)}\n`;
18987
+ };
18988
+ const installGemini = (opts) => {
18989
+ const paths = resolveGeminiPaths(opts);
18990
+ const result = emptyResult();
18991
+ const next = renderSettings(readIfExists(paths.settings));
18992
+ applyContent(result, opts, paths.settings, next, "register AfterTool hook");
18993
+ const fenced = upsertMarkdownFence(readIfExists(paths.aislopMd), AISLOP_MD_BODY, sentinelHash(AISLOP_MD_BODY)).nextContent;
18994
+ applyContent(result, opts, paths.aislopMd, fenced, "write AISLOP.md rules");
18995
+ const existingGeminiMd = readIfExists(paths.geminiMd) ?? "";
18996
+ const marker = "@AISLOP.md";
18997
+ if (!existingGeminiMd.includes(marker)) {
18998
+ const joiner = existingGeminiMd.endsWith("\n") || existingGeminiMd.length === 0 ? "" : "\n";
18999
+ const prefix = existingGeminiMd.length === 0 ? "" : `${existingGeminiMd}${joiner}\n`;
19000
+ applyContent(result, opts, paths.geminiMd, `${prefix}${marker}\n`, "append @AISLOP.md reference");
19001
+ } else result.skipped.push(paths.geminiMd);
19002
+ return result;
19003
+ };
19004
+ const uninstallGemini = (opts) => {
19005
+ const paths = resolveGeminiPaths(opts);
19006
+ const result = {
19007
+ removed: [],
19008
+ skipped: []
19009
+ };
19010
+ const raw = readIfExists(paths.settings);
19011
+ if (raw) {
19012
+ let obj = {};
19013
+ try {
19014
+ obj = JSON.parse(raw);
19015
+ } catch {
19016
+ obj = {};
19017
+ }
19018
+ const stripped = removeAislopEntries(obj, "AfterTool").next;
19019
+ const stillHasHooks = stripped.hooks && typeof stripped.hooks === "object" && Object.keys(stripped.hooks).length > 0;
19020
+ const otherKeys = Object.keys(stripped).filter((k) => k !== "hooks");
19021
+ if (!stillHasHooks && otherKeys.length === 0) applyRemoval(result, opts, paths.settings, null);
19022
+ else applyRemoval(result, opts, paths.settings, `${JSON.stringify(stripped, null, 2)}\n`);
19023
+ } else result.skipped.push(paths.settings);
19024
+ if (readIfExists(paths.aislopMd) != null) applyRemoval(result, opts, paths.aislopMd, null);
19025
+ else result.skipped.push(paths.aislopMd);
19026
+ const geminiMd = readIfExists(paths.geminiMd);
19027
+ if (geminiMd?.includes("@AISLOP.md")) {
19028
+ const stripped = geminiMd.split("\n").filter((l) => l.trim() !== "@AISLOP.md").join("\n").replace(/\n{3,}/g, "\n\n").trim();
19029
+ applyRemoval(result, opts, paths.geminiMd, stripped.length === 0 ? null : `${stripped}\n`);
19030
+ } else result.skipped.push(paths.geminiMd);
19031
+ return result;
19032
+ };
19033
+ //#endregion
19034
+ //#region src/hooks/install/kilocode.ts
19035
+ const resolveKilocodePaths = (opts) => ({ rules: path.join(opts.cwd, ".kilocode", "rules", "aislop-rules.md") });
19036
+ const installKilocode = (opts) => {
19037
+ if (opts.scope !== "project") return {
19038
+ wrote: [],
19039
+ skipped: [],
19040
+ planned: [{
19041
+ path: ".kilocode/rules/aislop-rules.md",
19042
+ summary: "Kilo Code is project-scope only; pass --project"
19043
+ }]
19044
+ };
19045
+ return installRulesOnly(opts, resolveKilocodePaths(opts), "write .kilocode/rules/aislop-rules.md");
19046
+ };
19047
+ const uninstallKilocode = (opts) => {
19048
+ if (opts.scope !== "project") return {
19049
+ removed: [],
19050
+ skipped: []
19051
+ };
19052
+ return uninstallRulesOnly(opts, resolveKilocodePaths(opts));
19053
+ };
19054
+ //#endregion
19055
+ //#region src/hooks/install/pi.ts
19056
+ const resolvePiPaths = (opts) => {
19057
+ return { extension: opts.scope === "project" ? path.join(opts.cwd, ".pi", "extensions", "aislop.js") : path.join(opts.home, ".pi", "agent", "extensions", "aislop.js") };
19058
+ };
19059
+ const PI_EXTENSION_SOURCE = `// aislop — auto-generated pi extension. Do not edit by hand.
19060
+ // Reinstall with: aislop hook install --pi
19061
+ import { spawn } from "node:child_process";
19062
+
19063
+ // Runs the CLI async so a slow scan never blocks pi's event loop, and forwards
19064
+ // ctx.signal so Esc actually kills the child instead of it running to completion.
19065
+ const runHook = (bin, args, input, signal) =>
19066
+ new Promise((resolve) => {
19067
+ const child = spawn(bin, args, { stdio: ["pipe", "pipe", "ignore"] });
19068
+ const chunks = [];
19069
+ let settled = false;
19070
+ let timer;
19071
+ const onAbort = () => {
19072
+ child.kill();
19073
+ finish(null);
19074
+ };
19075
+ const cleanup = () => {
19076
+ if (timer !== undefined) clearTimeout(timer);
19077
+ signal?.removeEventListener("abort", onAbort);
19078
+ };
19079
+ const finish = (value) => {
19080
+ if (settled) return;
19081
+ settled = true;
19082
+ cleanup();
19083
+ resolve(value);
19084
+ };
19085
+ signal?.addEventListener("abort", onAbort, { once: true });
19086
+ timer = setTimeout(() => {
19087
+ child.kill();
19088
+ finish(null);
19089
+ }, 15000);
19090
+ child.stdout.on("data", (c) => chunks.push(c));
19091
+ child.on("error", () => finish(null));
19092
+ child.on("close", (code) => {
19093
+ finish(code === 0 && chunks.length > 0 ? Buffer.concat(chunks).toString("utf-8") : null);
19094
+ });
19095
+ child.stdin.end(input);
19096
+ });
19097
+
19098
+ export default function (pi) {
19099
+ pi.on("tool_result", async (event, ctx) => {
19100
+ if (event.toolName !== "edit" && event.toolName !== "write") return;
19101
+ if (event.isError) return;
19102
+ const filePath = event.input && event.input.path;
19103
+ if (typeof filePath !== "string" || filePath.length === 0) return;
19104
+
19105
+ const bin = process.env.AISLOP_BIN || "aislop";
19106
+ const payload = JSON.stringify({
19107
+ cwd: ctx.cwd,
19108
+ file_path: filePath,
19109
+ tool_name: event.toolName,
19110
+ });
19111
+
19112
+ let out;
19113
+ try {
19114
+ const stdout = await runHook(bin, ["hook", "pi"], payload, ctx.signal);
19115
+ if (!stdout) return;
19116
+ out = JSON.parse(stdout);
19117
+ } catch {
19118
+ return;
19119
+ }
19120
+ if (!out || !out.message) return;
19121
+
19122
+ return {
19123
+ content: [...event.content, { type: "text", text: out.message }],
19124
+ isError: event.isError,
19125
+ };
19126
+ });
19127
+ }
19128
+ `;
19129
+ const installPi = (opts) => {
19130
+ const paths = resolvePiPaths(opts);
19131
+ const result = emptyResult();
19132
+ applyContent(result, opts, paths.extension, PI_EXTENSION_SOURCE, "write pi aislop extension");
19133
+ return result;
19134
+ };
19135
+ const uninstallPi = (opts) => {
19136
+ const paths = resolvePiPaths(opts);
19137
+ const result = {
19138
+ removed: [],
19139
+ skipped: []
19140
+ };
19141
+ if (readIfExists(paths.extension) != null) applyRemoval(result, opts, paths.extension, null);
19142
+ else result.skipped.push(paths.extension);
19143
+ return result;
19144
+ };
19145
+ //#endregion
19146
+ //#region src/hooks/install/windsurf.ts
19147
+ const resolveWindsurfPaths = (opts) => ({ rules: path.join(opts.cwd, ".windsurfrules") });
19148
+ const installWindsurf = (opts) => {
19149
+ if (opts.scope !== "project") return {
19150
+ wrote: [],
19151
+ skipped: [],
19152
+ planned: [{
19153
+ path: ".windsurfrules",
19154
+ summary: "Windsurf is project-scope only; pass --project"
19155
+ }]
19156
+ };
19157
+ return installRulesOnly(opts, resolveWindsurfPaths(opts), "write .windsurfrules");
19158
+ };
19159
+ const uninstallWindsurf = (opts) => {
19160
+ if (opts.scope !== "project") return {
19161
+ removed: [],
19162
+ skipped: []
19163
+ };
19164
+ return uninstallRulesOnly(opts, resolveWindsurfPaths(opts));
19165
+ };
19166
+ //#endregion
19167
+ //#region src/hooks/install/registry.ts
19168
+ const ALL_AGENTS = [
19169
+ "claude",
19170
+ "cursor",
19171
+ "gemini",
19172
+ "pi",
19173
+ "codex",
19174
+ "windsurf",
19175
+ "cline",
19176
+ "kilocode",
19177
+ "antigravity",
19178
+ "copilot"
19179
+ ];
19180
+ const AGENTS_PROJECT_ONLY = [
19181
+ "windsurf",
19182
+ "cline",
19183
+ "kilocode",
19184
+ "antigravity",
19185
+ "copilot"
19186
+ ];
19187
+ const AGENTS_SUPPORTING_BOTH_SCOPES = [
19188
+ "claude",
19189
+ "cursor",
19190
+ "gemini",
19191
+ "pi",
19192
+ "codex"
19193
+ ];
19194
+ const paths = {
19195
+ claude: (opts) => {
19196
+ const p = resolveClaudePaths(opts);
19197
+ return [
19198
+ p.settings,
19199
+ p.aislopMd,
19200
+ p.claudeMd
19201
+ ];
19202
+ },
19203
+ cursor: (opts) => {
19204
+ const p = resolveCursorPaths(opts);
19205
+ return opts.scope === "project" ? [p.hooks, p.rules] : [p.hooks];
19206
+ },
19207
+ gemini: (opts) => {
19208
+ const p = resolveGeminiPaths(opts);
19209
+ return [
19210
+ p.settings,
19211
+ p.aislopMd,
19212
+ p.geminiMd
19213
+ ];
19214
+ },
19215
+ pi: (opts) => [resolvePiPaths(opts).extension],
19216
+ codex: (opts) => [resolveCodexPaths(opts).rules],
19217
+ windsurf: (opts) => [resolveWindsurfPaths(opts).rules],
19218
+ cline: (opts) => [resolveClinePaths(opts).rules, resolveRooPaths(opts).rules],
19219
+ kilocode: (opts) => [resolveKilocodePaths(opts).rules],
19220
+ antigravity: (opts) => [resolveAntigravityPaths(opts).rules],
19221
+ copilot: (opts) => [resolveCopilotPaths(opts).rules]
19222
+ };
19223
+ const REGISTRY = {
19224
+ claude: {
19225
+ install: installClaude,
19226
+ uninstall: uninstallClaude,
19227
+ paths: paths.claude
19228
+ },
19229
+ cursor: {
19230
+ install: installCursor,
19231
+ uninstall: uninstallCursor,
19232
+ paths: paths.cursor
19233
+ },
19234
+ gemini: {
19235
+ install: installGemini,
19236
+ uninstall: uninstallGemini,
19237
+ paths: paths.gemini
19238
+ },
19239
+ pi: {
19240
+ install: installPi,
19241
+ uninstall: uninstallPi,
19242
+ paths: paths.pi
19243
+ },
19244
+ codex: {
19245
+ install: installCodex,
19246
+ uninstall: uninstallCodex,
19247
+ paths: paths.codex
19248
+ },
19249
+ windsurf: {
19250
+ install: installWindsurf,
19251
+ uninstall: uninstallWindsurf,
19252
+ paths: paths.windsurf
19253
+ },
19254
+ cline: {
19255
+ install: installCline,
19256
+ uninstall: uninstallCline,
19257
+ paths: paths.cline
19258
+ },
19259
+ kilocode: {
19260
+ install: installKilocode,
19261
+ uninstall: uninstallKilocode,
19262
+ paths: paths.kilocode
19263
+ },
19264
+ antigravity: {
19265
+ install: installAntigravity,
19266
+ uninstall: uninstallAntigravity,
19267
+ paths: paths.antigravity
19268
+ },
19269
+ copilot: {
19270
+ install: installCopilot,
19271
+ uninstall: uninstallCopilot,
19272
+ paths: paths.copilot
19273
+ }
19274
+ };
19275
+ const defaultScopeFor = (agent) => AGENTS_PROJECT_ONLY.includes(agent) ? "project" : "global";
19276
+ const HOOK_MARKERS = [
19277
+ "__raigal",
19278
+ "raigal:begin",
19279
+ "@RAIGAL.md",
19280
+ "__aislop",
19281
+ "aislop:begin",
19282
+ "@AISLOP.md"
19283
+ ];
19284
+ const hasHook = (target) => {
19285
+ try {
19286
+ if (!fs.statSync(target).isFile()) return false;
19287
+ const base = path.basename(target).toLowerCase();
19288
+ if (base.startsWith("raigal") || base.startsWith("aislop")) return true;
19289
+ const content = fs.readFileSync(target, "utf8");
19290
+ return HOOK_MARKERS.some((marker) => content.includes(marker));
19291
+ } catch {
19292
+ return false;
19293
+ }
19294
+ };
19295
+ const detectionScopes = (agent) => AGENTS_SUPPORTING_BOTH_SCOPES.includes(agent) ? ["global", "project"] : [defaultScopeFor(agent)];
19296
+ const detectRaigalHooks = (opts) => ALL_AGENTS.filter((agent) => detectionScopes(agent).some((scope) => REGISTRY[agent].paths({
19297
+ home: opts.home,
19298
+ cwd: opts.cwd,
19299
+ scope
19300
+ }).some(hasHook)));
18201
19301
  //#endregion
18202
19302
  //#region src/output/terminal.ts
18203
19303
  const groupBy = (items, key) => {
@@ -18365,27 +19465,16 @@ const printEngineStatus = (result) => {
18365
19465
  }
18366
19466
  };
18367
19467
  //#endregion
18368
- //#region src/scoring/rule-severity.ts
18369
- /**
18370
- * Apply per-rule severity overrides from config: "off" drops the diagnostic,
18371
- * "error"/"warning" rewrite its severity before scoring and rendering.
18372
- */
18373
- const applyRuleSeverities = (diagnostics, overrides) => {
18374
- if (Object.keys(overrides).length === 0) return diagnostics;
18375
- const result = [];
18376
- for (const diagnostic of diagnostics) {
18377
- const override = overrides[diagnostic.rule];
18378
- if (!override) {
18379
- result.push(diagnostic);
18380
- continue;
18381
- }
18382
- if (override === "off") continue;
18383
- result.push(override === diagnostic.severity ? diagnostic : {
18384
- ...diagnostic,
18385
- severity: override
18386
- });
18387
- }
18388
- return result;
19468
+ //#region src/commands/scan-hook-nudge.ts
19469
+ const shouldShowHookNudge = (ctx) => ctx.isTty && !ctx.isCi && ctx.installedAgentCount === 0;
19470
+ const buildHookNudge = (ctx) => {
19471
+ if (!shouldShowHookNudge(ctx)) return null;
19472
+ return [
19473
+ "",
19474
+ "Next: install the per-edit hook so your coding agent fixes slop before it lands.",
19475
+ ctx.invocation === "raigal" ? " raigal hook install" : " npm install -g @methiu/raigal && raigal hook install",
19476
+ ""
19477
+ ].join("\n");
18389
19478
  };
18390
19479
  //#endregion
18391
19480
  //#region src/commands/scan-coverage.ts
@@ -18960,7 +20049,7 @@ const buildScanNextSteps = (input) => {
18960
20049
  emphasis: "primary",
18961
20050
  label: "Provider",
18962
20051
  command: `${input.invocation} agent --provider codex`,
18963
- detail: "or use --provider claude/opencode"
20052
+ detail: "or use --provider claude/opencode/pi"
18964
20053
  }, {
18965
20054
  emphasis: "muted",
18966
20055
  label: "Preview",
@@ -19119,6 +20208,8 @@ const runScanBody = async (resolvedDir, config, options, projectInfo, scanScope,
19119
20208
  const configDir = findConfigDir(resolvedDir);
19120
20209
  const rulesPath = configDir ? path.join(configDir, RULES_FILE) : void 0;
19121
20210
  const engineConfig = {
20211
+ overrides: config.overrides,
20212
+ rules: config.rules,
19122
20213
  quality: config.quality,
19123
20214
  security: config.security,
19124
20215
  lint: config.lint,
@@ -19140,7 +20231,7 @@ const runScanBody = async (resolvedDir, config, options, projectInfo, scanScope,
19140
20231
  config: engineConfig
19141
20232
  }, config.engines, machineOutput)).map((result) => ({
19142
20233
  ...result,
19143
- diagnostics: applyRuleSeverities(result.diagnostics, config.rules)
20234
+ diagnostics: config.overrides.length ? result.diagnostics : applyRuleSeverities(result.diagnostics, config.rules)
19144
20235
  })), resolvedDir);
19145
20236
  if (suppressedCount > 0 && !machineOutput) log.muted(`Suppressed ${suppressedCount} finding(s) via raigal-ignore directives`);
19146
20237
  const changeMap = options.changes && !options.staged ? getChangedLineMap(resolvedDir, options.base) : null;
@@ -19178,6 +20269,7 @@ const runScanBody = async (resolvedDir, config, options, projectInfo, scanScope,
19178
20269
  engineIssues[r.engine] = r.diagnostics.length;
19179
20270
  engineTimings[r.engine] = Math.round(r.elapsed);
19180
20271
  }
20272
+ const enginesFailed = results.filter((r) => r.failed).map((r) => r.engine);
19181
20273
  const completion = {
19182
20274
  exitCode,
19183
20275
  score: scoreable ? scoreResult.score : null,
@@ -19187,7 +20279,8 @@ const runScanBody = async (resolvedDir, config, options, projectInfo, scanScope,
19187
20279
  warningCount: allDiagnostics.filter((d) => d.severity === "warning").length,
19188
20280
  fixableCount: allDiagnostics.filter((d) => d.fixable).length,
19189
20281
  engineIssues,
19190
- engineTimings
20282
+ engineTimings,
20283
+ ...enginesFailed.length > 0 ? { properties: { engines_failed: enginesFailed } } : {}
19191
20284
  };
19192
20285
  recordScanToCloud({
19193
20286
  results,
@@ -19200,12 +20293,12 @@ const runScanBody = async (resolvedDir, config, options, projectInfo, scanScope,
19200
20293
  scoreFileCount
19201
20294
  });
19202
20295
  if (options.sarif) {
19203
- const { buildSarifLog } = await import("./sarif-Do8E1xhZ.js");
20296
+ const { buildSarifLog } = await import("./sarif-Ba5D8TPH.js");
19204
20297
  console.log(JSON.stringify(buildSarifLog(results), null, 2));
19205
20298
  return completion;
19206
20299
  }
19207
20300
  if (options.json) {
19208
- const { buildJsonOutput } = await import("./json-C2IwKr9o.js");
20301
+ const { buildJsonOutput } = await import("./json-DBoMe4xB.js");
19209
20302
  const jsonOut = buildJsonOutput(results, scoreResult, scoreFileCount, elapsedMs, scanCoverage);
19210
20303
  console.log(JSON.stringify(jsonOut, null, 2));
19211
20304
  return completion;
@@ -19238,6 +20331,18 @@ const runScanBody = async (resolvedDir, config, options, projectInfo, scanScope,
19238
20331
  printBrand: options.printBrand,
19239
20332
  showPilotInvitation
19240
20333
  }));
20334
+ if (options.command !== "ci" && options.printBrand !== false) {
20335
+ const nudge = buildHookNudge({
20336
+ installedAgentCount: detectRaigalHooks({
20337
+ home: os.homedir(),
20338
+ cwd: resolvedDir
20339
+ }).length,
20340
+ isTty: Boolean(process.stdout.isTTY),
20341
+ isCi: isCiEnv(),
20342
+ invocation: detectInvocation()
20343
+ });
20344
+ if (nudge) process.stdout.write(nudge);
20345
+ }
19241
20346
  return completion;
19242
20347
  };
19243
20348
  //#endregion
@@ -19402,6 +20507,8 @@ const runFixBody = async (resolvedDir, config, options, projectInfo) => {
19402
20507
  const configDir = findConfigDir(resolvedDir);
19403
20508
  const rulesPath = configDir ? path.join(configDir, RULES_FILE) : void 0;
19404
20509
  const engineConfig = {
20510
+ overrides: config.overrides,
20511
+ rules: config.rules,
19405
20512
  quality: config.quality,
19406
20513
  security: config.security,
19407
20514
  lint: config.lint,
@@ -19427,7 +20534,7 @@ const runFixBody = async (resolvedDir, config, options, projectInfo) => {
19427
20534
  status: "done",
19428
20535
  label: "Verification complete"
19429
20536
  });
19430
- const scanResults = appendPostFixLintResult(verificationResults, collectPostFixLintDiagnostics(steps));
20537
+ const scanResults = appendPostFixLintResult(verificationResults, applyFileOverrides(collectPostFixLintDiagnostics(steps), context));
19431
20538
  const allDiagnostics = scanResults.flatMap((r) => r.diagnostics);
19432
20539
  const scoreResult = calculateScore(allDiagnostics, config.scoring.weights, config.scoring.thresholds, scope ? scope.scoreFileCount : projectInfo.sourceFileCount, config.scoring.smoothing, config.scoring.maxPerRule);
19433
20540
  const errors = allDiagnostics.filter((d) => d.severity === "error").length;
@@ -19489,6 +20596,13 @@ const runFixBody = async (resolvedDir, config, options, projectInfo) => {
19489
20596
  };
19490
20597
  };
19491
20598
  //#endregion
20599
+ //#region src/ui/prompts.ts
20600
+ const runCancellable = async (fn) => {
20601
+ const value = await fn();
20602
+ if (isCancel(value)) return void 0;
20603
+ return value;
20604
+ };
20605
+ //#endregion
19492
20606
  //#region src/commands/init.ts
19493
20607
  const buildInitSuccessRender = (input) => {
19494
20608
  const deps = {
@@ -19558,13 +20672,13 @@ const writeGithubWorkflow = (rootDirectory, enabled) => {
19558
20672
  };
19559
20673
  };
19560
20674
  const promptForConfigChoices = async () => {
19561
- const enginesSelection = await multiselect({
20675
+ const enginesSelection = await runCancellable(() => multiselect({
19562
20676
  message: "Which engines should run?",
19563
20677
  options: ENGINE_CHOICES,
19564
20678
  initialValues: DEFAULT_ENGINE_SELECTION,
19565
20679
  required: false
19566
- });
19567
- if (isCancel(enginesSelection)) return null;
20680
+ }));
20681
+ if (enginesSelection === void 0) return null;
19568
20682
  const failBelowRaw = await text({
19569
20683
  message: "CI quality gate — fail the build when the score drops below (0-100)",
19570
20684
  initialValue: "70",