aislop 0.5.1 → 0.6.0

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/cli.js CHANGED
@@ -1026,7 +1026,49 @@ const MEANINGFUL_JSDOC_TAGS = new Set([
1026
1026
  "todo",
1027
1027
  "link",
1028
1028
  "license",
1029
- "preserve"
1029
+ "preserve",
1030
+ "swagger",
1031
+ "openapi",
1032
+ "route",
1033
+ "group",
1034
+ "summary",
1035
+ "description",
1036
+ "operationid",
1037
+ "response",
1038
+ "responses",
1039
+ "request",
1040
+ "requestbody",
1041
+ "security",
1042
+ "tag",
1043
+ "tags",
1044
+ "path",
1045
+ "body",
1046
+ "query",
1047
+ "queryparam",
1048
+ "header",
1049
+ "headers",
1050
+ "produces",
1051
+ "accept",
1052
+ "middleware",
1053
+ "api",
1054
+ "apiname",
1055
+ "apidefine",
1056
+ "apigroup",
1057
+ "apiparam",
1058
+ "apiquery",
1059
+ "apibody",
1060
+ "apiheader",
1061
+ "apisuccess",
1062
+ "apierror",
1063
+ "apiexample",
1064
+ "apiversion",
1065
+ "apidescription",
1066
+ "apipermission",
1067
+ "apiuse",
1068
+ "apiignore",
1069
+ "apiprivate",
1070
+ "namespace",
1071
+ "category"
1030
1072
  ]);
1031
1073
  const SUPPORTED_EXTS = new Set([
1032
1074
  ".ts",
@@ -4657,7 +4699,7 @@ const discoverProject = async (directory) => {
4657
4699
  //#region src/utils/git.ts
4658
4700
  const MAX_BUFFER = 50 * 1024 * 1024;
4659
4701
  const getChangedFiles = (cwd, base) => {
4660
- const result = spawnSync("git", [
4702
+ const diff = spawnSync("git", [
4661
4703
  "diff",
4662
4704
  "--name-only",
4663
4705
  "--diff-filter=ACMR",
@@ -4667,8 +4709,22 @@ const getChangedFiles = (cwd, base) => {
4667
4709
  encoding: "utf-8",
4668
4710
  maxBuffer: MAX_BUFFER
4669
4711
  });
4670
- if (result.error || result.status !== 0) return [];
4671
- return result.stdout.split("\n").filter((f) => f.length > 0).map((f) => path.resolve(cwd, f));
4712
+ if (diff.error || diff.status !== 0) return [];
4713
+ const untracked = spawnSync("git", [
4714
+ "ls-files",
4715
+ "--others",
4716
+ "--exclude-standard"
4717
+ ], {
4718
+ cwd,
4719
+ encoding: "utf-8",
4720
+ maxBuffer: MAX_BUFFER
4721
+ });
4722
+ const names = /* @__PURE__ */ new Set();
4723
+ for (const line of diff.stdout.split("\n")) if (line.length > 0) names.add(line);
4724
+ if (!untracked.error && untracked.status === 0) {
4725
+ for (const line of untracked.stdout.split("\n")) if (line.length > 0) names.add(line);
4726
+ }
4727
+ return Array.from(names).map((f) => path.resolve(cwd, f));
4672
4728
  };
4673
4729
  const getStagedFiles = (cwd) => {
4674
4730
  const result = spawnSync("git", [
@@ -4691,7 +4747,7 @@ const getStagedFiles = (cwd) => {
4691
4747
  * Application version — injected at build time by tsdown from package.json.
4692
4748
  * The fallback should always match the "version" field in package.json.
4693
4749
  */
4694
- const APP_VERSION = "0.5.1";
4750
+ const APP_VERSION = "0.6.0";
4695
4751
 
4696
4752
  //#endregion
4697
4753
  //#region src/utils/telemetry.ts
@@ -6305,7 +6361,13 @@ const fixDependencyAudit = async (context, onProgress) => {
6305
6361
  await tryNpmOverrides(context.rootDirectory, onProgress);
6306
6362
  return;
6307
6363
  }
6308
- await tryPnpmOverrides(context.rootDirectory, onProgress);
6364
+ if (await tryPnpmOverrides(context.rootDirectory, onProgress)) return;
6365
+ if (fs.existsSync(path.join(context.rootDirectory, "package-lock.json"))) {
6366
+ await runNpmAuditFix(context.rootDirectory, onProgress);
6367
+ await tryNpmOverrides(context.rootDirectory, onProgress);
6368
+ return;
6369
+ }
6370
+ onProgress?.("Dependency audit fixes · skipping (pnpm audit unavailable and no package-lock.json for npm fallback)");
6309
6371
  };
6310
6372
  const runNpmAuditFix = async (rootDir, onProgress) => {
6311
6373
  onProgress?.("Dependency audit fixes · running npm audit fix (can take a few minutes)");
@@ -6337,11 +6399,11 @@ const fetchLatestVersion = async (rootDir, pkgName, pm) => {
6337
6399
  return null;
6338
6400
  }
6339
6401
  };
6340
- const collectNpmOverrides = async (rootDir, vulnerabilities) => {
6402
+ const collectOverrides = async (rootDir, vulnerabilities, pm) => {
6341
6403
  const overrides = {};
6342
6404
  for (const [pkgName, vuln] of Object.entries(vulnerabilities)) {
6343
6405
  if (vuln.fixAvailable !== false || !vuln.range) continue;
6344
- const latest = await fetchLatestVersion(rootDir, pkgName, "npm");
6406
+ const latest = await fetchLatestVersion(rootDir, pkgName, pm);
6345
6407
  if (latest) overrides[pkgName] = latest;
6346
6408
  }
6347
6409
  return overrides;
@@ -6355,7 +6417,7 @@ const tryNpmOverrides = async (rootDir, onProgress) => {
6355
6417
  if (!auditResult.stdout) return;
6356
6418
  const vulnerabilities = JSON.parse(auditResult.stdout).vulnerabilities;
6357
6419
  if (!vulnerabilities) return;
6358
- const overrides = await collectNpmOverrides(rootDir, vulnerabilities);
6420
+ const overrides = await collectOverrides(rootDir, vulnerabilities, "npm");
6359
6421
  if (Object.keys(overrides).length === 0) return;
6360
6422
  const pkgPath = path.join(rootDir, "package.json");
6361
6423
  const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8"));
@@ -6391,23 +6453,31 @@ const collectPnpmOverrides = (advisories) => {
6391
6453
  }
6392
6454
  return overrides;
6393
6455
  };
6456
+ const isPnpmAuditRetired = (stdout, stderr) => {
6457
+ const haystack = `${stdout}\n${stderr}`.toLowerCase();
6458
+ return haystack.includes("410") || haystack.includes("gone") || haystack.includes("retired") || haystack.includes("endpoint") || haystack.includes("err_pnpm_audit") || haystack.includes("audit endpoint");
6459
+ };
6394
6460
  const tryPnpmOverrides = async (rootDir, onProgress) => {
6395
6461
  onProgress?.("Dependency audit fixes · running pnpm audit");
6396
6462
  const auditResult = await runSubprocess("pnpm", ["audit", "--json"], {
6397
6463
  cwd: rootDir,
6398
6464
  timeout: AUDIT_TIMEOUT
6399
6465
  });
6400
- if (!auditResult.stdout) return;
6466
+ if (!auditResult.stdout) {
6467
+ if (isPnpmAuditRetired(auditResult.stdout ?? "", auditResult.stderr ?? "")) return false;
6468
+ return auditResult.exitCode === 0;
6469
+ }
6401
6470
  let parsed;
6402
6471
  try {
6403
6472
  parsed = JSON.parse(auditResult.stdout);
6404
6473
  } catch {
6405
- return;
6474
+ if (auditResult.exitCode !== 0 || isPnpmAuditRetired(auditResult.stdout, auditResult.stderr ?? "")) return false;
6475
+ return true;
6406
6476
  }
6407
6477
  const advisories = parsed.advisories;
6408
- if (!advisories || Object.keys(advisories).length === 0) return;
6478
+ if (!advisories || Object.keys(advisories).length === 0) return true;
6409
6479
  const overrides = collectPnpmOverrides(advisories);
6410
- if (Object.keys(overrides).length === 0) return;
6480
+ if (Object.keys(overrides).length === 0) return true;
6411
6481
  const pkgPath = path.join(rootDir, "package.json");
6412
6482
  const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8"));
6413
6483
  const pnpmBlock = pkg.pnpm ?? {};
@@ -6425,6 +6495,7 @@ const tryPnpmOverrides = async (rootDir, onProgress) => {
6425
6495
  cwd: rootDir,
6426
6496
  timeout: INSTALL_TIMEOUT
6427
6497
  });
6498
+ return true;
6428
6499
  };
6429
6500
  const fixExpoDependencies = async (context, onProgress) => {
6430
6501
  await removeDisallowedExpoPackages(context.rootDirectory, onProgress);
@@ -6450,6 +6521,10 @@ const fixExpoDependencies = async (context, onProgress) => {
6450
6521
  });
6451
6522
  if (checkResult.exitCode !== 0) throw new Error(checkResult.stderr || checkResult.stdout || "expo dependency check failed");
6452
6523
  };
6524
+ /**
6525
+ * Run expo-doctor to detect packages that should not be installed directly,
6526
+ * then uninstall them. No hardcoded list — expo-doctor is the source of truth.
6527
+ */
6453
6528
  const removeDisallowedExpoPackages = async (rootDir, onProgress) => {
6454
6529
  try {
6455
6530
  onProgress?.("Expo dependency alignment · running expo-doctor");
@@ -6757,30 +6832,43 @@ const buildFeedback = (diagnostics, score, rootDirectory, baseline) => {
6757
6832
  };
6758
6833
 
6759
6834
  //#endregion
6760
- //#region src/hooks/adapters/claude.ts
6761
- const extractFiles = (stdin) => {
6762
- const files = /* @__PURE__ */ new Set();
6763
- const input = stdin.tool_input ?? {};
6764
- if (typeof input.file_path === "string" && input.file_path.length > 0) files.add(input.file_path);
6765
- if (Array.isArray(input.edits)) {
6766
- for (const e of input.edits) if (e && typeof e.file_path === "string" && e.file_path.length > 0) files.add(e.file_path);
6835
+ //#region src/hooks/io/scan-lock.ts
6836
+ const LOCK_DIR = ".aislop";
6837
+ const LOCK_FILE = "hook.lock";
6838
+ const STALE_MS = 3e4;
6839
+ const lockPath = (cwd) => path.join(cwd, LOCK_DIR, LOCK_FILE);
6840
+ const readLock = (target) => {
6841
+ try {
6842
+ const raw = fs.readFileSync(target, "utf-8");
6843
+ const parsed = JSON.parse(raw);
6844
+ if (typeof parsed.pid !== "number" || typeof parsed.ts !== "number") return null;
6845
+ return parsed;
6846
+ } catch {
6847
+ return null;
6767
6848
  }
6768
- return Array.from(files);
6769
6849
  };
6770
- const parseClaudeStdin = (raw) => {
6771
- if (!raw.trim()) return {};
6850
+ const acquireHookLock = (cwd) => {
6851
+ const target = lockPath(cwd);
6852
+ const existing = readLock(target);
6853
+ if (existing && Date.now() - existing.ts < STALE_MS) return null;
6772
6854
  try {
6773
- return JSON.parse(raw);
6855
+ fs.mkdirSync(path.dirname(target), { recursive: true });
6856
+ fs.writeFileSync(target, JSON.stringify({
6857
+ pid: process.pid,
6858
+ ts: Date.now()
6859
+ }));
6774
6860
  } catch {
6775
- return {};
6861
+ return null;
6776
6862
  }
6863
+ return () => {
6864
+ try {
6865
+ if (readLock(target)?.pid === process.pid) fs.unlinkSync(target);
6866
+ } catch {}
6867
+ };
6777
6868
  };
6778
- const readStdin = async () => {
6779
- if (process.stdin.isTTY) return "";
6780
- const chunks = [];
6781
- for await (const chunk of process.stdin) chunks.push(chunk);
6782
- return Buffer.concat(chunks).toString("utf-8");
6783
- };
6869
+
6870
+ //#endregion
6871
+ //#region src/hooks/io/scoped-scan.ts
6784
6872
  const existingAbsolutePaths = (cwd, files) => files.map((f) => path.isAbsolute(f) ? f : path.join(cwd, f)).filter((p) => {
6785
6873
  try {
6786
6874
  return fs.statSync(p).isFile();
@@ -6788,6 +6876,11 @@ const existingAbsolutePaths = (cwd, files) => files.map((f) => path.isAbsolute(f
6788
6876
  return false;
6789
6877
  }
6790
6878
  });
6879
+ const resolveHookFiles = (cwd, files) => {
6880
+ const direct = existingAbsolutePaths(cwd, files);
6881
+ if (direct.length > 0) return direct;
6882
+ return existingAbsolutePaths(cwd, getChangedFiles(cwd));
6883
+ };
6791
6884
  const runScopedScan = async (cwd, filePaths) => {
6792
6885
  const project = await discoverProject(cwd);
6793
6886
  const config = loadConfig(cwd);
@@ -6822,6 +6915,146 @@ const runScopedScan = async (cwd, filePaths) => {
6822
6915
  rootDirectory: project.rootDirectory
6823
6916
  };
6824
6917
  };
6918
+
6919
+ //#endregion
6920
+ //#region src/hooks/io/atomic-write.ts
6921
+ const atomicWrite = (targetPath, content) => {
6922
+ const dir = path.dirname(targetPath);
6923
+ fs.mkdirSync(dir, { recursive: true });
6924
+ const rand = Math.random().toString(36).slice(2, 10);
6925
+ const tmp = path.join(dir, `.aislop-tmp-${process.pid}-${rand}`);
6926
+ fs.writeFileSync(tmp, content, "utf-8");
6927
+ fs.renameSync(tmp, targetPath);
6928
+ };
6929
+ const readIfExists = (targetPath) => {
6930
+ try {
6931
+ return fs.readFileSync(targetPath, "utf-8");
6932
+ } catch {
6933
+ return null;
6934
+ }
6935
+ };
6936
+
6937
+ //#endregion
6938
+ //#region src/hooks/quality-gate/baseline.ts
6939
+ const BASELINE_REL = path.join(".aislop", "baseline.json");
6940
+ const baselinePath = (cwd) => path.join(cwd, BASELINE_REL);
6941
+ const readBaseline = (cwd) => {
6942
+ const raw = readIfExists(baselinePath(cwd));
6943
+ if (!raw) return null;
6944
+ try {
6945
+ const parsed = JSON.parse(raw);
6946
+ if (parsed.schema !== "aislop.baseline.v1") return null;
6947
+ return parsed;
6948
+ } catch {
6949
+ return null;
6950
+ }
6951
+ };
6952
+ const writeBaseline = (cwd, baseline) => {
6953
+ const target = baselinePath(cwd);
6954
+ atomicWrite(target, `${JSON.stringify(baseline, null, 2)}\n`);
6955
+ return target;
6956
+ };
6957
+ const captureBaseline = async (cwd) => {
6958
+ const project = await discoverProject(cwd);
6959
+ const config = loadConfig(cwd);
6960
+ const results = await runEngines({
6961
+ rootDirectory: project.rootDirectory,
6962
+ languages: project.languages,
6963
+ frameworks: project.frameworks,
6964
+ files: [],
6965
+ installedTools: project.installedTools,
6966
+ config: {
6967
+ quality: config.quality,
6968
+ security: {
6969
+ audit: false,
6970
+ auditTimeout: 0
6971
+ }
6972
+ }
6973
+ }, {
6974
+ format: config.engines.format,
6975
+ lint: config.engines.lint,
6976
+ "code-quality": config.engines["code-quality"],
6977
+ "ai-slop": config.engines["ai-slop"],
6978
+ architecture: config.engines.architecture,
6979
+ security: false
6980
+ });
6981
+ const diagnostics = results.flatMap((r) => r.diagnostics);
6982
+ const { score } = calculateScore(diagnostics, config.scoring.weights, config.scoring.thresholds, project.sourceFileCount, config.scoring.smoothing);
6983
+ const byEngine = {};
6984
+ for (const r of results) {
6985
+ const { score: engineScore } = calculateScore(diagnostics.filter((d) => r.diagnostics.includes(d)), config.scoring.weights, config.scoring.thresholds, project.sourceFileCount, config.scoring.smoothing);
6986
+ byEngine[r.engine] = engineScore;
6987
+ }
6988
+ const target = writeBaseline(cwd, {
6989
+ schema: "aislop.baseline.v1",
6990
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString(),
6991
+ score,
6992
+ byEngine,
6993
+ fileCount: project.sourceFileCount
6994
+ });
6995
+ return {
6996
+ score,
6997
+ fileCount: project.sourceFileCount,
6998
+ path: target
6999
+ };
7000
+ };
7001
+ const appendSessionFiles = (cwd, files) => {
7002
+ if (files.length === 0) return;
7003
+ const target = path.join(cwd, ".aislop", "session.jsonl");
7004
+ try {
7005
+ fs.mkdirSync(path.dirname(target), { recursive: true });
7006
+ const line = `${JSON.stringify({
7007
+ ts: Date.now(),
7008
+ files
7009
+ })}\n`;
7010
+ fs.appendFileSync(target, line);
7011
+ } catch {}
7012
+ };
7013
+ const readSessionFiles = (cwd) => {
7014
+ const raw = readIfExists(path.join(cwd, ".aislop", "session.jsonl"));
7015
+ if (!raw) return [];
7016
+ const files = /* @__PURE__ */ new Set();
7017
+ for (const line of raw.split("\n")) {
7018
+ if (!line.trim()) continue;
7019
+ try {
7020
+ const entry = JSON.parse(line);
7021
+ for (const f of entry.files ?? []) files.add(f);
7022
+ } catch {}
7023
+ }
7024
+ return Array.from(files);
7025
+ };
7026
+ const clearSessionFiles = (cwd) => {
7027
+ const target = path.join(cwd, ".aislop", "session.jsonl");
7028
+ try {
7029
+ fs.unlinkSync(target);
7030
+ } catch {}
7031
+ };
7032
+
7033
+ //#endregion
7034
+ //#region src/hooks/adapters/claude.ts
7035
+ const extractFiles$2 = (stdin) => {
7036
+ const files = /* @__PURE__ */ new Set();
7037
+ const input = stdin.tool_input ?? {};
7038
+ if (typeof input.file_path === "string" && input.file_path.length > 0) files.add(input.file_path);
7039
+ if (Array.isArray(input.edits)) {
7040
+ for (const e of input.edits) if (e && typeof e.file_path === "string" && e.file_path.length > 0) files.add(e.file_path);
7041
+ }
7042
+ return Array.from(files);
7043
+ };
7044
+ const parseClaudeStdin = (raw) => {
7045
+ if (!raw.trim()) return {};
7046
+ try {
7047
+ return JSON.parse(raw);
7048
+ } catch {
7049
+ return {};
7050
+ }
7051
+ };
7052
+ const readStdin$2 = async () => {
7053
+ if (process.stdin.isTTY) return "";
7054
+ const chunks = [];
7055
+ for await (const chunk of process.stdin) chunks.push(chunk);
7056
+ return Buffer.concat(chunks).toString("utf-8");
7057
+ };
6825
7058
  const renderClaudeOutput = (additional, block) => {
6826
7059
  const out = { hookSpecificOutput: {
6827
7060
  hookEventName: "PostToolUse",
@@ -6834,20 +7067,167 @@ const renderClaudeOutput = (additional, block) => {
6834
7067
  return out;
6835
7068
  };
6836
7069
  const runClaudeHook = async (deps = {}) => {
6837
- const getStdin = deps.stdin ?? readStdin;
7070
+ const getStdin = deps.stdin ?? readStdin$2;
6838
7071
  const write = deps.write ?? ((s) => process.stdout.write(s));
6839
7072
  const input = parseClaudeStdin(await getStdin());
6840
7073
  const cwd = input.cwd && path.isAbsolute(input.cwd) ? input.cwd : process.cwd();
6841
- const absFiles = existingAbsolutePaths(cwd, extractFiles(input));
6842
- if (absFiles.length === 0) return 0;
7074
+ const files = resolveHookFiles(cwd, extractFiles$2(input));
7075
+ if (files.length === 0) return 0;
7076
+ const release = acquireHookLock(cwd);
7077
+ if (!release) return 0;
6843
7078
  try {
6844
- const { diagnostics, score, rootDirectory } = await runScopedScan(cwd, absFiles);
6845
- const feedback = buildFeedback(diagnostics, score, rootDirectory);
7079
+ const { diagnostics, score, rootDirectory } = await runScopedScan(cwd, files);
7080
+ const baseline = readBaseline(cwd);
7081
+ appendSessionFiles(cwd, files);
7082
+ const feedback = buildFeedback(diagnostics, score, rootDirectory, baseline?.score);
6846
7083
  const envelope = renderClaudeOutput(JSON.stringify(feedback));
6847
7084
  write(JSON.stringify(envelope));
6848
7085
  return 0;
6849
7086
  } catch {
6850
7087
  return 0;
7088
+ } finally {
7089
+ release();
7090
+ }
7091
+ };
7092
+ const parseClaudeStopStdin = (raw) => {
7093
+ if (!raw.trim()) return {};
7094
+ try {
7095
+ return JSON.parse(raw);
7096
+ } catch {
7097
+ return {};
7098
+ }
7099
+ };
7100
+ const runClaudeStopHook = async (deps = {}) => {
7101
+ const getStdin = deps.stdin ?? readStdin$2;
7102
+ const write = deps.write ?? ((s) => process.stdout.write(s));
7103
+ const input = parseClaudeStopStdin(await getStdin());
7104
+ const cwd = input.cwd && path.isAbsolute(input.cwd) ? input.cwd : process.cwd();
7105
+ if (input.stop_hook_active) return 0;
7106
+ const baseline = readBaseline(cwd);
7107
+ if (!baseline) return 0;
7108
+ const sessionFiles = readSessionFiles(cwd);
7109
+ if (sessionFiles.length === 0) return 0;
7110
+ const release = acquireHookLock(cwd);
7111
+ if (!release) return 0;
7112
+ try {
7113
+ const { diagnostics, score, rootDirectory } = await runScopedScan(cwd, sessionFiles);
7114
+ const feedback = buildFeedback(diagnostics, score, rootDirectory, baseline.score);
7115
+ if (!feedback.regressed) {
7116
+ clearSessionFiles(cwd);
7117
+ return 0;
7118
+ }
7119
+ const envelope = renderClaudeOutput(JSON.stringify(feedback), { reason: `aislop: score dropped from ${baseline.score} to ${score}. Fix the ${feedback.counts.total} finding${feedback.counts.total === 1 ? "" : "s"} before finishing.` });
7120
+ write(JSON.stringify(envelope));
7121
+ return 0;
7122
+ } catch {
7123
+ return 0;
7124
+ } finally {
7125
+ release();
7126
+ }
7127
+ };
7128
+
7129
+ //#endregion
7130
+ //#region src/hooks/adapters/cursor.ts
7131
+ const extractFiles$1 = (stdin) => {
7132
+ const files = /* @__PURE__ */ new Set();
7133
+ if (typeof stdin.file_path === "string" && stdin.file_path.length > 0) files.add(stdin.file_path);
7134
+ if (Array.isArray(stdin.edits)) {
7135
+ for (const e of stdin.edits) if (e && typeof e.file_path === "string" && e.file_path.length > 0) files.add(e.file_path);
7136
+ }
7137
+ const input = stdin.tool_input ?? {};
7138
+ if (typeof input.file_path === "string" && input.file_path.length > 0) files.add(input.file_path);
7139
+ if (Array.isArray(input.edits)) {
7140
+ for (const e of input.edits) if (e && typeof e.file_path === "string" && e.file_path.length > 0) files.add(e.file_path);
7141
+ }
7142
+ return Array.from(files);
7143
+ };
7144
+ const parseCursorStdin = (raw) => {
7145
+ if (!raw.trim()) return {};
7146
+ try {
7147
+ return JSON.parse(raw);
7148
+ } catch {
7149
+ return {};
7150
+ }
7151
+ };
7152
+ const renderCursorOutput = (additional, event = "afterFileEdit") => ({ hookSpecificOutput: {
7153
+ hookEventName: event,
7154
+ additionalContext: additional
7155
+ } });
7156
+ const readStdin$1 = async () => {
7157
+ if (process.stdin.isTTY) return "";
7158
+ const chunks = [];
7159
+ for await (const chunk of process.stdin) chunks.push(chunk);
7160
+ return Buffer.concat(chunks).toString("utf-8");
7161
+ };
7162
+ const runCursorHook = async (deps = {}) => {
7163
+ const getStdin = deps.stdin ?? readStdin$1;
7164
+ const write = deps.write ?? ((s) => process.stdout.write(s));
7165
+ const writeErr = deps.writeErr ?? ((s) => process.stderr.write(s));
7166
+ const input = parseCursorStdin(await getStdin());
7167
+ const cwd = input.cwd && path.isAbsolute(input.cwd) ? input.cwd : process.cwd();
7168
+ const files = resolveHookFiles(cwd, extractFiles$1(input));
7169
+ if (files.length === 0) return 0;
7170
+ const release = acquireHookLock(cwd);
7171
+ if (!release) return 0;
7172
+ try {
7173
+ const { diagnostics, score, rootDirectory } = await runScopedScan(cwd, files);
7174
+ const feedback = buildFeedback(diagnostics, score, rootDirectory);
7175
+ const serialized = JSON.stringify(feedback);
7176
+ write(JSON.stringify(renderCursorOutput(serialized)));
7177
+ writeErr(`${serialized}\n`);
7178
+ return 0;
7179
+ } catch {
7180
+ return 0;
7181
+ } finally {
7182
+ release();
7183
+ }
7184
+ };
7185
+
7186
+ //#endregion
7187
+ //#region src/hooks/adapters/gemini.ts
7188
+ const extractFiles = (stdin) => {
7189
+ const files = /* @__PURE__ */ new Set();
7190
+ const input = stdin.tool_input ?? {};
7191
+ if (typeof input.file_path === "string" && input.file_path.length > 0) files.add(input.file_path);
7192
+ if (typeof input.path === "string" && input.path.length > 0) files.add(input.path);
7193
+ return Array.from(files);
7194
+ };
7195
+ const parseGeminiStdin = (raw) => {
7196
+ if (!raw.trim()) return {};
7197
+ try {
7198
+ return JSON.parse(raw);
7199
+ } catch {
7200
+ return {};
7201
+ }
7202
+ };
7203
+ const renderGeminiOutput = (additional) => ({ hookSpecificOutput: {
7204
+ hookEventName: "AfterTool",
7205
+ additionalContext: additional
7206
+ } });
7207
+ const readStdin = async () => {
7208
+ if (process.stdin.isTTY) return "";
7209
+ const chunks = [];
7210
+ for await (const chunk of process.stdin) chunks.push(chunk);
7211
+ return Buffer.concat(chunks).toString("utf-8");
7212
+ };
7213
+ const runGeminiHook = async (deps = {}) => {
7214
+ const getStdin = deps.stdin ?? readStdin;
7215
+ const write = deps.write ?? ((s) => process.stdout.write(s));
7216
+ const input = parseGeminiStdin(await getStdin());
7217
+ const cwd = input.cwd && path.isAbsolute(input.cwd) ? input.cwd : process.cwd();
7218
+ const files = resolveHookFiles(cwd, extractFiles(input));
7219
+ if (files.length === 0) return 0;
7220
+ const release = acquireHookLock(cwd);
7221
+ if (!release) return 0;
7222
+ try {
7223
+ const { diagnostics, score, rootDirectory } = await runScopedScan(cwd, files);
7224
+ const feedback = buildFeedback(diagnostics, score, rootDirectory);
7225
+ write(JSON.stringify(renderGeminiOutput(JSON.stringify(feedback))));
7226
+ return 0;
7227
+ } catch {
7228
+ return 0;
7229
+ } finally {
7230
+ release();
6851
7231
  }
6852
7232
  };
6853
7233
 
@@ -6855,7 +7235,7 @@ const runClaudeHook = async (deps = {}) => {
6855
7235
  //#region src/hooks/assets.ts
6856
7236
  const AISLOP_MD_BODY = `# aislop — agent instructions
6857
7237
 
6858
- [aislop](https://github.com/heavykenny/aislop) 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, 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.
7238
+ [aislop](https://github.com/scanaislop/aislop) 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, 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.
6859
7239
 
6860
7240
  ## On every edit
6861
7241
 
@@ -6881,21 +7261,129 @@ A PostToolUse hook runs \`aislop hook claude\` after every Edit, Write, or Multi
6881
7261
  `;
6882
7262
 
6883
7263
  //#endregion
6884
- //#region src/hooks/io/atomic-write.ts
6885
- const atomicWrite = (targetPath, content) => {
6886
- const dir = path.dirname(targetPath);
6887
- fs.mkdirSync(dir, { recursive: true });
6888
- const rand = Math.random().toString(36).slice(2, 10);
6889
- const tmp = path.join(dir, `.aislop-tmp-${process.pid}-${rand}`);
6890
- fs.writeFileSync(tmp, content, "utf-8");
6891
- fs.renameSync(tmp, targetPath);
7264
+ //#region src/hooks/io/sentinel.ts
7265
+ const sentinelHash = (content) => `sha256:${crypto.createHash("sha256").update(content).digest("hex").slice(0, 32)}`;
7266
+ const BEGIN_RE = /<!--\s*aislop:begin\s+v(\d+)(?:\s+hash=([^\s>]+))?\s*-->/;
7267
+ const END_RE = /<!--\s*aislop:end\s+v\d+\s*-->/;
7268
+ const renderFence = (body, hash) => [
7269
+ `<!-- aislop:begin v1 hash=${hash} -->`,
7270
+ body.trimEnd(),
7271
+ "<!-- aislop:end v1 -->"
7272
+ ].join("\n");
7273
+ const upsertMarkdownFence = (existing, body, hash) => {
7274
+ const fenced = renderFence(body, hash);
7275
+ if (existing == null || existing.length === 0) return {
7276
+ nextContent: `${fenced}\n`,
7277
+ replaced: false
7278
+ };
7279
+ const begin = existing.match(BEGIN_RE);
7280
+ const end = existing.match(END_RE);
7281
+ if (begin && end && (end.index ?? 0) > (begin.index ?? 0)) return {
7282
+ nextContent: `${existing.slice(0, begin.index)}${fenced}${existing.slice((end.index ?? 0) + end[0].length)}`,
7283
+ replaced: true
7284
+ };
7285
+ return {
7286
+ nextContent: `${existing}${existing.endsWith("\n") ? "" : "\n"}\n${fenced}\n`,
7287
+ replaced: false
7288
+ };
6892
7289
  };
6893
- const readIfExists = (targetPath) => {
6894
- try {
6895
- return fs.readFileSync(targetPath, "utf-8");
6896
- } catch {
6897
- return null;
7290
+
7291
+ //#endregion
7292
+ //#region src/hooks/install/types.ts
7293
+ const emptyResult = () => ({
7294
+ wrote: [],
7295
+ skipped: [],
7296
+ planned: []
7297
+ });
7298
+ const applyContent = (result, opts, target, nextContent, summary) => {
7299
+ if (readIfExists(target) === nextContent) {
7300
+ result.skipped.push(target);
7301
+ return;
7302
+ }
7303
+ if (opts.dryRun) {
7304
+ result.planned.push({
7305
+ path: target,
7306
+ summary
7307
+ });
7308
+ return;
7309
+ }
7310
+ atomicWrite(target, nextContent);
7311
+ result.wrote.push(target);
7312
+ };
7313
+ const applyRemoval = (result, opts, target, nextContent) => {
7314
+ const existing = readIfExists(target);
7315
+ if (existing == null) {
7316
+ result.skipped.push(target);
7317
+ return;
7318
+ }
7319
+ if (existing === (nextContent ?? "")) {
7320
+ result.skipped.push(target);
7321
+ return;
7322
+ }
7323
+ if (opts.dryRun) {
7324
+ result.removed.push(target);
7325
+ return;
7326
+ }
7327
+ if (nextContent == null) try {
7328
+ fs.unlinkSync(target);
7329
+ } catch {}
7330
+ else atomicWrite(target, nextContent);
7331
+ result.removed.push(target);
7332
+ };
7333
+
7334
+ //#endregion
7335
+ //#region src/hooks/install/rules-only.ts
7336
+ const installRulesOnly = (opts, paths, summary) => {
7337
+ const result = emptyResult();
7338
+ const next = upsertMarkdownFence(readIfExists(paths.rules), AISLOP_MD_BODY, sentinelHash(AISLOP_MD_BODY)).nextContent;
7339
+ applyContent(result, opts, paths.rules, next, summary);
7340
+ if (paths.host && paths.marker) {
7341
+ const host = readIfExists(paths.host) ?? "";
7342
+ if (!host.includes(paths.marker)) {
7343
+ const joiner = host.endsWith("\n") || host.length === 0 ? "" : "\n";
7344
+ const prefix = host.length === 0 ? "" : `${host}${joiner}\n`;
7345
+ applyContent(result, opts, paths.host, `${prefix}${paths.marker}\n`, `append ${paths.marker} reference`);
7346
+ } else result.skipped.push(paths.host);
7347
+ }
7348
+ return result;
7349
+ };
7350
+ const uninstallRulesOnly = (opts, paths) => {
7351
+ const result = {
7352
+ removed: [],
7353
+ skipped: []
7354
+ };
7355
+ if (readIfExists(paths.rules) != null) applyRemoval(result, opts, paths.rules, null);
7356
+ else result.skipped.push(paths.rules);
7357
+ if (paths.host && paths.marker) {
7358
+ const host = readIfExists(paths.host);
7359
+ if (host != null && host.includes(paths.marker)) {
7360
+ const stripped = host.split("\n").filter((l) => l.trim() !== paths.marker).join("\n").replace(/\n{3,}/g, "\n\n").trim();
7361
+ applyRemoval(result, opts, paths.host, stripped.length === 0 ? null : `${stripped}\n`);
7362
+ } else result.skipped.push(paths.host);
6898
7363
  }
7364
+ return result;
7365
+ };
7366
+
7367
+ //#endregion
7368
+ //#region src/hooks/install/antigravity.ts
7369
+ const resolveAntigravityPaths = (opts) => ({ rules: path.join(opts.cwd, ".agents", "rules", "antigravity-aislop-rules.md") });
7370
+ const installAntigravity = (opts) => {
7371
+ if (opts.scope !== "project") return {
7372
+ wrote: [],
7373
+ skipped: [],
7374
+ planned: [{
7375
+ path: ".agents/rules/antigravity-aislop-rules.md",
7376
+ summary: "Antigravity is project-scope only; pass --project"
7377
+ }]
7378
+ };
7379
+ return installRulesOnly(opts, resolveAntigravityPaths(opts), "write .agents/rules/antigravity-aislop-rules.md");
7380
+ };
7381
+ const uninstallAntigravity = (opts) => {
7382
+ if (opts.scope !== "project") return {
7383
+ removed: [],
7384
+ skipped: []
7385
+ };
7386
+ return uninstallRulesOnly(opts, resolveAntigravityPaths(opts));
6899
7387
  };
6900
7388
 
6901
7389
  //#endregion
@@ -6918,43 +7406,44 @@ const upsertHookGroup = (config, event, group) => {
6918
7406
  };
6919
7407
  return next;
6920
7408
  };
6921
-
6922
- //#endregion
6923
- //#region src/hooks/io/sentinel.ts
6924
- const sentinelHash = (content) => `sha256:${crypto.createHash("sha256").update(content).digest("hex").slice(0, 32)}`;
6925
- const BEGIN_RE = /<!--\s*aislop:begin\s+v(\d+)(?:\s+hash=([^\s>]+))?\s*-->/;
6926
- const END_RE = /<!--\s*aislop:end\s+v\d+\s*-->/;
6927
- const renderFence = (body, hash) => [
6928
- `<!-- aislop:begin v1 hash=${hash} -->`,
6929
- body.trimEnd(),
6930
- "<!-- aislop:end v1 -->"
6931
- ].join("\n");
6932
- const upsertMarkdownFence = (existing, body, hash) => {
6933
- const fenced = renderFence(body, hash);
6934
- if (existing == null || existing.length === 0) return {
6935
- nextContent: `${fenced}\n`,
6936
- replaced: false
6937
- };
6938
- const begin = existing.match(BEGIN_RE);
6939
- const end = existing.match(END_RE);
6940
- if (begin && end && (end.index ?? 0) > (begin.index ?? 0)) return {
6941
- nextContent: `${existing.slice(0, begin.index)}${fenced}${existing.slice((end.index ?? 0) + end[0].length)}`,
6942
- replaced: true
7409
+ const upsertFlatHook = (config, event, entry) => {
7410
+ const next = { ...config };
7411
+ const hooks = next.hooks && typeof next.hooks === "object" ? next.hooks : {};
7412
+ const cleaned = (Array.isArray(hooks[event]) ? hooks[event] : []).filter((e) => !isAislopManaged(e));
7413
+ next.hooks = {
7414
+ ...hooks,
7415
+ [event]: [...cleaned, entry]
6943
7416
  };
7417
+ return next;
7418
+ };
7419
+ const removeAislopEntries = (config, event) => {
7420
+ const next = { ...config };
7421
+ const hooks = next.hooks && typeof next.hooks === "object" ? next.hooks : {};
7422
+ const existing = Array.isArray(hooks[event]) ? hooks[event] : [];
7423
+ const cleaned = existing.filter((e) => !isAislopManaged(e) && !groupIsAislop(e));
7424
+ const removed = existing.length - cleaned.length;
7425
+ const nextHooks = { ...hooks };
7426
+ if (cleaned.length === 0) delete nextHooks[event];
7427
+ else nextHooks[event] = cleaned;
7428
+ if (Object.keys(nextHooks).length === 0) delete next.hooks;
7429
+ else next.hooks = nextHooks;
6944
7430
  return {
6945
- nextContent: `${existing}${existing.endsWith("\n") ? "" : "\n"}\n${fenced}\n`,
6946
- replaced: false
7431
+ next,
7432
+ removed
6947
7433
  };
6948
7434
  };
6949
7435
 
6950
7436
  //#endregion
6951
7437
  //#region src/hooks/install/claude.ts
6952
- const resolveClaudePaths = (home) => ({
6953
- settings: path.join(home, ".claude", "settings.json"),
6954
- aislopMd: path.join(home, ".claude", "AISLOP.md"),
6955
- claudeMd: path.join(home, ".claude", "CLAUDE.md")
6956
- });
6957
- const buildHookGroup = () => {
7438
+ const resolveClaudePaths = (opts) => {
7439
+ const root = opts.scope === "project" ? path.join(opts.cwd, ".claude") : path.join(opts.home, ".claude");
7440
+ return {
7441
+ settings: path.join(root, "settings.json"),
7442
+ aislopMd: path.join(root, "AISLOP.md"),
7443
+ claudeMd: path.join(root, "CLAUDE.md")
7444
+ };
7445
+ };
7446
+ const buildHookGroup$1 = () => {
6958
7447
  const hashBody = JSON.stringify({
6959
7448
  command: "aislop hook claude",
6960
7449
  matcher: "Edit|Write|MultiEdit"
@@ -6972,73 +7461,559 @@ const buildHookGroup = () => {
6972
7461
  }]
6973
7462
  };
6974
7463
  };
6975
- const installClaude = (opts = { home: os.homedir() }) => {
6976
- const paths = resolveClaudePaths(opts.home);
6977
- const wrote = [];
6978
- const skipped = [];
6979
- const existingSettingsRaw = readIfExists(paths.settings);
6980
- let settingsObj = {};
6981
- if (existingSettingsRaw) try {
6982
- settingsObj = JSON.parse(existingSettingsRaw);
7464
+ const buildStopHookGroup = () => {
7465
+ const hashBody = JSON.stringify({ command: "aislop hook claude --stop" });
7466
+ return {
7467
+ matcher: "",
7468
+ hooks: [{
7469
+ type: "command",
7470
+ command: "aislop hook claude --stop",
7471
+ [AISLOP_SENTINEL_KEY]: {
7472
+ v: 1,
7473
+ managed: true,
7474
+ hash: sentinelHash(hashBody)
7475
+ }
7476
+ }]
7477
+ };
7478
+ };
7479
+ const renderSettings$1 = (existingRaw, qualityGate) => {
7480
+ let obj = {};
7481
+ if (existingRaw) try {
7482
+ obj = JSON.parse(existingRaw);
6983
7483
  } catch {
6984
- atomicWrite(`${paths.settings}.aislop-bak`, existingSettingsRaw);
6985
- }
6986
- const nextSettings = upsertHookGroup(settingsObj, "PostToolUse", buildHookGroup());
6987
- const nextSettingsStr = `${JSON.stringify(nextSettings, null, 2)}\n`;
6988
- if (nextSettingsStr !== existingSettingsRaw) {
6989
- atomicWrite(paths.settings, nextSettingsStr);
6990
- wrote.push(paths.settings);
6991
- } else skipped.push(paths.settings);
7484
+ obj = {};
7485
+ }
7486
+ let next = upsertHookGroup(obj, "PostToolUse", buildHookGroup$1());
7487
+ if (qualityGate) next = upsertHookGroup(next, "Stop", buildStopHookGroup());
7488
+ else next = removeAislopEntries(next, "Stop").next;
7489
+ return `${JSON.stringify(next, null, 2)}\n`;
7490
+ };
7491
+ const installClaude = (opts) => {
7492
+ const paths = resolveClaudePaths(opts);
7493
+ const result = emptyResult();
7494
+ const nextSettings = renderSettings$1(readIfExists(paths.settings), Boolean(opts.qualityGate));
7495
+ applyContent(result, opts, paths.settings, nextSettings, "register PostToolUse hook");
6992
7496
  const mdHash = sentinelHash(AISLOP_MD_BODY);
6993
- const existingMd = readIfExists(paths.aislopMd);
6994
- const fenced = upsertMarkdownFence(existingMd, AISLOP_MD_BODY, mdHash);
6995
- if (fenced.nextContent !== existingMd) {
6996
- atomicWrite(paths.aislopMd, fenced.nextContent);
6997
- wrote.push(paths.aislopMd);
6998
- } else skipped.push(paths.aislopMd);
7497
+ const fenced = upsertMarkdownFence(readIfExists(paths.aislopMd), AISLOP_MD_BODY, mdHash);
7498
+ applyContent(result, opts, paths.aislopMd, fenced.nextContent, "write AISLOP.md rules");
6999
7499
  const existingClaudeMd = readIfExists(paths.claudeMd) ?? "";
7000
7500
  const marker = "@AISLOP.md";
7001
7501
  if (!existingClaudeMd.includes(marker)) {
7002
7502
  const joiner = existingClaudeMd.endsWith("\n") || existingClaudeMd.length === 0 ? "" : "\n";
7003
- const nextClaudeMd = `${existingClaudeMd.length === 0 ? "" : `${existingClaudeMd}${joiner}\n`}${marker}\n`;
7004
- atomicWrite(paths.claudeMd, nextClaudeMd);
7005
- wrote.push(paths.claudeMd);
7006
- } else skipped.push(paths.claudeMd);
7503
+ const prefix = existingClaudeMd.length === 0 ? "" : `${existingClaudeMd}${joiner}\n`;
7504
+ applyContent(result, opts, paths.claudeMd, `${prefix}${marker}\n`, "append @AISLOP.md reference");
7505
+ } else result.skipped.push(paths.claudeMd);
7506
+ return result;
7507
+ };
7508
+ const uninstallClaude = (opts) => {
7509
+ const paths = resolveClaudePaths({
7510
+ ...opts,
7511
+ qualityGate: false
7512
+ });
7513
+ const result = {
7514
+ removed: [],
7515
+ skipped: []
7516
+ };
7517
+ const settingsRaw = readIfExists(paths.settings);
7518
+ if (settingsRaw) {
7519
+ let obj = {};
7520
+ try {
7521
+ obj = JSON.parse(settingsRaw);
7522
+ } catch {
7523
+ obj = {};
7524
+ }
7525
+ const stripped = removeAislopEntries(removeAislopEntries(obj, "PostToolUse").next, "Stop").next;
7526
+ const stillHasHooks = stripped.hooks && typeof stripped.hooks === "object" && Object.keys(stripped.hooks).length > 0;
7527
+ const otherKeys = Object.keys(stripped).filter((k) => k !== "hooks");
7528
+ if (!stillHasHooks && otherKeys.length === 0) applyRemoval(result, opts, paths.settings, null);
7529
+ else applyRemoval(result, opts, paths.settings, `${JSON.stringify(stripped, null, 2)}\n`);
7530
+ } else result.skipped.push(paths.settings);
7531
+ if (readIfExists(paths.aislopMd) != null) applyRemoval(result, opts, paths.aislopMd, null);
7532
+ else result.skipped.push(paths.aislopMd);
7533
+ const claudeMd = readIfExists(paths.claudeMd);
7534
+ if (claudeMd != null && claudeMd.includes("@AISLOP.md")) {
7535
+ const stripped = claudeMd.split("\n").filter((line) => line.trim() !== "@AISLOP.md").join("\n").replace(/\n{3,}/g, "\n\n").trim();
7536
+ applyRemoval(result, opts, paths.claudeMd, stripped.length === 0 ? null : `${stripped}\n`);
7537
+ } else result.skipped.push(paths.claudeMd);
7538
+ return result;
7539
+ };
7540
+
7541
+ //#endregion
7542
+ //#region src/hooks/install/cline.ts
7543
+ const resolveClinePaths = (opts) => ({ rules: path.join(opts.cwd, ".clinerules") });
7544
+ const resolveRooPaths = (opts) => ({ rules: path.join(opts.cwd, ".roo", "rules", "aislop.md") });
7545
+ const installCline = (opts) => {
7546
+ if (opts.scope !== "project") return {
7547
+ wrote: [],
7548
+ skipped: [],
7549
+ planned: [{
7550
+ path: ".clinerules",
7551
+ summary: "Cline is project-scope only; pass --project"
7552
+ }]
7553
+ };
7554
+ const cline = installRulesOnly(opts, resolveClinePaths(opts), "write .clinerules");
7555
+ const roo = installRulesOnly(opts, resolveRooPaths(opts), "write .roo/rules/aislop.md");
7007
7556
  return {
7008
- wrote,
7009
- skipped
7557
+ wrote: [...cline.wrote, ...roo.wrote],
7558
+ skipped: [...cline.skipped, ...roo.skipped],
7559
+ planned: [...cline.planned, ...roo.planned]
7560
+ };
7561
+ };
7562
+ const uninstallCline = (opts) => {
7563
+ if (opts.scope !== "project") return {
7564
+ removed: [],
7565
+ skipped: []
7566
+ };
7567
+ const a = uninstallRulesOnly(opts, resolveClinePaths(opts));
7568
+ const b = uninstallRulesOnly(opts, resolveRooPaths(opts));
7569
+ return {
7570
+ removed: [...a.removed, ...b.removed],
7571
+ skipped: [...a.skipped, ...b.skipped]
7010
7572
  };
7011
7573
  };
7012
7574
 
7013
7575
  //#endregion
7014
- //#region src/commands/hook.ts
7015
- const hookInstall = async (opts) => {
7016
- if (opts.agent !== "claude") {
7017
- process.stderr.write(`hook install: agent "${opts.agent}" not implemented yet\n`);
7018
- process.exitCode = 1;
7019
- return;
7576
+ //#region src/hooks/install/codex.ts
7577
+ const resolveCodexPaths = (opts) => ({ rules: opts.scope === "project" ? path.join(opts.cwd, "AGENTS.md") : path.join(opts.home, ".codex", "AGENTS.md") });
7578
+ const installCodex = (opts) => installRulesOnly(opts, resolveCodexPaths(opts), "write AGENTS.md rules for Codex");
7579
+ const uninstallCodex = (opts) => uninstallRulesOnly(opts, resolveCodexPaths(opts));
7580
+
7581
+ //#endregion
7582
+ //#region src/hooks/install/copilot.ts
7583
+ const resolveCopilotPaths = (opts) => ({ rules: path.join(opts.cwd, ".github", "copilot-instructions.md") });
7584
+ const installCopilot = (opts) => {
7585
+ if (opts.scope !== "project") return {
7586
+ wrote: [],
7587
+ skipped: [],
7588
+ planned: [{
7589
+ path: ".github/copilot-instructions.md",
7590
+ summary: "Copilot is project-scope only; pass --project"
7591
+ }]
7592
+ };
7593
+ return installRulesOnly(opts, resolveCopilotPaths(opts), "write .github/copilot-instructions.md");
7594
+ };
7595
+ const uninstallCopilot = (opts) => {
7596
+ if (opts.scope !== "project") return {
7597
+ removed: [],
7598
+ skipped: []
7599
+ };
7600
+ return uninstallRulesOnly(opts, resolveCopilotPaths(opts));
7601
+ };
7602
+
7603
+ //#endregion
7604
+ //#region src/hooks/install/cursor.ts
7605
+ const resolveCursorPaths = (opts) => {
7606
+ const root = opts.scope === "project" ? path.join(opts.cwd, ".cursor") : path.join(opts.home, ".cursor");
7607
+ return {
7608
+ hooks: path.join(root, "hooks.json"),
7609
+ rules: path.join(opts.cwd, ".cursor", "rules", "aislop.mdc")
7610
+ };
7611
+ };
7612
+ const buildHookEntry = () => {
7613
+ const hashBody = JSON.stringify({
7614
+ command: "aislop hook cursor",
7615
+ timeout: 5e3
7616
+ });
7617
+ return {
7618
+ command: "aislop hook cursor",
7619
+ type: "command",
7620
+ timeout: 5e3,
7621
+ [AISLOP_SENTINEL_KEY]: {
7622
+ v: 1,
7623
+ managed: true,
7624
+ hash: sentinelHash(hashBody)
7625
+ }
7626
+ };
7627
+ };
7628
+ const renderHooksJson = (existingRaw) => {
7629
+ let obj = { version: 1 };
7630
+ if (existingRaw) try {
7631
+ obj = JSON.parse(existingRaw);
7632
+ } catch {
7633
+ obj = { version: 1 };
7020
7634
  }
7021
- if (!opts.global) {
7022
- process.stderr.write("hook install: only --global supported in this release\n");
7023
- process.exitCode = 1;
7024
- return;
7635
+ if (typeof obj.version !== "number") obj.version = 1;
7636
+ const next = upsertFlatHook(obj, "afterFileEdit", buildHookEntry());
7637
+ return `${JSON.stringify(next, null, 2)}\n`;
7638
+ };
7639
+ const installCursor = (opts) => {
7640
+ const paths = resolveCursorPaths(opts);
7641
+ const result = emptyResult();
7642
+ const nextHooks = renderHooksJson(readIfExists(paths.hooks));
7643
+ applyContent(result, opts, paths.hooks, nextHooks, "register afterFileEdit hook");
7644
+ if (opts.scope === "project") {
7645
+ const rules = upsertMarkdownFence(readIfExists(paths.rules), AISLOP_MD_BODY, sentinelHash(AISLOP_MD_BODY)).nextContent;
7646
+ applyContent(result, opts, paths.rules, rules, "write .cursor/rules/aislop.mdc");
7647
+ }
7648
+ return result;
7649
+ };
7650
+ const uninstallCursor = (opts) => {
7651
+ const paths = resolveCursorPaths(opts);
7652
+ const result = {
7653
+ removed: [],
7654
+ skipped: []
7655
+ };
7656
+ const raw = readIfExists(paths.hooks);
7657
+ if (raw) {
7658
+ let obj = {};
7659
+ try {
7660
+ obj = JSON.parse(raw);
7661
+ } catch {
7662
+ obj = {};
7663
+ }
7664
+ const stripped = removeAislopEntries(obj, "afterFileEdit").next;
7665
+ const stillHasHooks = stripped.hooks && typeof stripped.hooks === "object" && Object.keys(stripped.hooks).length > 0;
7666
+ const otherKeys = Object.keys(stripped).filter((k) => k !== "hooks" && k !== "version");
7667
+ if (!stillHasHooks && otherKeys.length === 0) applyRemoval(result, opts, paths.hooks, null);
7668
+ else applyRemoval(result, opts, paths.hooks, `${JSON.stringify(stripped, null, 2)}\n`);
7669
+ } else result.skipped.push(paths.hooks);
7670
+ if (opts.scope === "project") applyRemoval(result, opts, paths.rules, null);
7671
+ return result;
7672
+ };
7673
+
7674
+ //#endregion
7675
+ //#region src/hooks/install/gemini.ts
7676
+ const resolveGeminiPaths = (opts) => {
7677
+ const root = opts.scope === "project" ? path.join(opts.cwd, ".gemini") : path.join(opts.home, ".gemini");
7678
+ return {
7679
+ settings: path.join(root, "settings.json"),
7680
+ aislopMd: path.join(root, "AISLOP.md"),
7681
+ geminiMd: path.join(root, "GEMINI.md")
7682
+ };
7683
+ };
7684
+ const buildHookGroup = () => {
7685
+ const hashBody = JSON.stringify({
7686
+ command: "aislop hook gemini",
7687
+ matcher: "write_file|replace"
7688
+ });
7689
+ return {
7690
+ matcher: "write_file|replace",
7691
+ hooks: [{
7692
+ name: "aislop",
7693
+ type: "command",
7694
+ command: "aislop hook gemini",
7695
+ timeout: 5e3,
7696
+ [AISLOP_SENTINEL_KEY]: {
7697
+ v: 1,
7698
+ managed: true,
7699
+ hash: sentinelHash(hashBody)
7700
+ }
7701
+ }]
7702
+ };
7703
+ };
7704
+ const renderSettings = (existingRaw) => {
7705
+ let obj = {};
7706
+ if (existingRaw) try {
7707
+ obj = JSON.parse(existingRaw);
7708
+ } catch {
7709
+ obj = {};
7710
+ }
7711
+ const next = upsertHookGroup(obj, "AfterTool", buildHookGroup());
7712
+ return `${JSON.stringify(next, null, 2)}\n`;
7713
+ };
7714
+ const installGemini = (opts) => {
7715
+ const paths = resolveGeminiPaths(opts);
7716
+ const result = emptyResult();
7717
+ const next = renderSettings(readIfExists(paths.settings));
7718
+ applyContent(result, opts, paths.settings, next, "register AfterTool hook");
7719
+ const fenced = upsertMarkdownFence(readIfExists(paths.aislopMd), AISLOP_MD_BODY, sentinelHash(AISLOP_MD_BODY)).nextContent;
7720
+ applyContent(result, opts, paths.aislopMd, fenced, "write AISLOP.md rules");
7721
+ const existingGeminiMd = readIfExists(paths.geminiMd) ?? "";
7722
+ const marker = "@AISLOP.md";
7723
+ if (!existingGeminiMd.includes(marker)) {
7724
+ const joiner = existingGeminiMd.endsWith("\n") || existingGeminiMd.length === 0 ? "" : "\n";
7725
+ const prefix = existingGeminiMd.length === 0 ? "" : `${existingGeminiMd}${joiner}\n`;
7726
+ applyContent(result, opts, paths.geminiMd, `${prefix}${marker}\n`, "append @AISLOP.md reference");
7727
+ } else result.skipped.push(paths.geminiMd);
7728
+ return result;
7729
+ };
7730
+ const uninstallGemini = (opts) => {
7731
+ const paths = resolveGeminiPaths(opts);
7732
+ const result = {
7733
+ removed: [],
7734
+ skipped: []
7735
+ };
7736
+ const raw = readIfExists(paths.settings);
7737
+ if (raw) {
7738
+ let obj = {};
7739
+ try {
7740
+ obj = JSON.parse(raw);
7741
+ } catch {
7742
+ obj = {};
7743
+ }
7744
+ const stripped = removeAislopEntries(obj, "AfterTool").next;
7745
+ const stillHasHooks = stripped.hooks && typeof stripped.hooks === "object" && Object.keys(stripped.hooks).length > 0;
7746
+ const otherKeys = Object.keys(stripped).filter((k) => k !== "hooks");
7747
+ if (!stillHasHooks && otherKeys.length === 0) applyRemoval(result, opts, paths.settings, null);
7748
+ else applyRemoval(result, opts, paths.settings, `${JSON.stringify(stripped, null, 2)}\n`);
7749
+ } else result.skipped.push(paths.settings);
7750
+ if (readIfExists(paths.aislopMd) != null) applyRemoval(result, opts, paths.aislopMd, null);
7751
+ else result.skipped.push(paths.aislopMd);
7752
+ const geminiMd = readIfExists(paths.geminiMd);
7753
+ if (geminiMd != null && geminiMd.includes("@AISLOP.md")) {
7754
+ const stripped = geminiMd.split("\n").filter((l) => l.trim() !== "@AISLOP.md").join("\n").replace(/\n{3,}/g, "\n\n").trim();
7755
+ applyRemoval(result, opts, paths.geminiMd, stripped.length === 0 ? null : `${stripped}\n`);
7756
+ } else result.skipped.push(paths.geminiMd);
7757
+ return result;
7758
+ };
7759
+
7760
+ //#endregion
7761
+ //#region src/hooks/install/kilocode.ts
7762
+ const resolveKilocodePaths = (opts) => ({ rules: path.join(opts.cwd, ".kilocode", "rules", "aislop-rules.md") });
7763
+ const installKilocode = (opts) => {
7764
+ if (opts.scope !== "project") return {
7765
+ wrote: [],
7766
+ skipped: [],
7767
+ planned: [{
7768
+ path: ".kilocode/rules/aislop-rules.md",
7769
+ summary: "Kilo Code is project-scope only; pass --project"
7770
+ }]
7771
+ };
7772
+ return installRulesOnly(opts, resolveKilocodePaths(opts), "write .kilocode/rules/aislop-rules.md");
7773
+ };
7774
+ const uninstallKilocode = (opts) => {
7775
+ if (opts.scope !== "project") return {
7776
+ removed: [],
7777
+ skipped: []
7778
+ };
7779
+ return uninstallRulesOnly(opts, resolveKilocodePaths(opts));
7780
+ };
7781
+
7782
+ //#endregion
7783
+ //#region src/hooks/install/windsurf.ts
7784
+ const resolveWindsurfPaths = (opts) => ({ rules: path.join(opts.cwd, ".windsurfrules") });
7785
+ const installWindsurf = (opts) => {
7786
+ if (opts.scope !== "project") return {
7787
+ wrote: [],
7788
+ skipped: [],
7789
+ planned: [{
7790
+ path: ".windsurfrules",
7791
+ summary: "Windsurf is project-scope only; pass --project"
7792
+ }]
7793
+ };
7794
+ return installRulesOnly(opts, resolveWindsurfPaths(opts), "write .windsurfrules");
7795
+ };
7796
+ const uninstallWindsurf = (opts) => {
7797
+ if (opts.scope !== "project") return {
7798
+ removed: [],
7799
+ skipped: []
7800
+ };
7801
+ return uninstallRulesOnly(opts, resolveWindsurfPaths(opts));
7802
+ };
7803
+
7804
+ //#endregion
7805
+ //#region src/hooks/install/registry.ts
7806
+ const ALL_AGENTS = [
7807
+ "claude",
7808
+ "cursor",
7809
+ "gemini",
7810
+ "codex",
7811
+ "windsurf",
7812
+ "cline",
7813
+ "kilocode",
7814
+ "antigravity",
7815
+ "copilot"
7816
+ ];
7817
+ const AGENTS_PROJECT_ONLY = [
7818
+ "windsurf",
7819
+ "cline",
7820
+ "kilocode",
7821
+ "antigravity",
7822
+ "copilot"
7823
+ ];
7824
+ const AGENTS_SUPPORTING_BOTH_SCOPES = [
7825
+ "claude",
7826
+ "cursor",
7827
+ "gemini",
7828
+ "codex"
7829
+ ];
7830
+ const paths = {
7831
+ claude: (opts) => {
7832
+ const p = resolveClaudePaths(opts);
7833
+ return [
7834
+ p.settings,
7835
+ p.aislopMd,
7836
+ p.claudeMd
7837
+ ];
7838
+ },
7839
+ cursor: (opts) => {
7840
+ const p = resolveCursorPaths(opts);
7841
+ return opts.scope === "project" ? [p.hooks, p.rules] : [p.hooks];
7842
+ },
7843
+ gemini: (opts) => {
7844
+ const p = resolveGeminiPaths(opts);
7845
+ return [
7846
+ p.settings,
7847
+ p.aislopMd,
7848
+ p.geminiMd
7849
+ ];
7850
+ },
7851
+ codex: (opts) => [resolveCodexPaths(opts).rules],
7852
+ windsurf: (opts) => [resolveWindsurfPaths(opts).rules],
7853
+ cline: (opts) => [resolveClinePaths(opts).rules, resolveRooPaths(opts).rules],
7854
+ kilocode: (opts) => [resolveKilocodePaths(opts).rules],
7855
+ antigravity: (opts) => [resolveAntigravityPaths(opts).rules],
7856
+ copilot: (opts) => [resolveCopilotPaths(opts).rules]
7857
+ };
7858
+ const REGISTRY = {
7859
+ claude: {
7860
+ install: installClaude,
7861
+ uninstall: uninstallClaude,
7862
+ paths: paths.claude
7863
+ },
7864
+ cursor: {
7865
+ install: installCursor,
7866
+ uninstall: uninstallCursor,
7867
+ paths: paths.cursor
7868
+ },
7869
+ gemini: {
7870
+ install: installGemini,
7871
+ uninstall: uninstallGemini,
7872
+ paths: paths.gemini
7873
+ },
7874
+ codex: {
7875
+ install: installCodex,
7876
+ uninstall: uninstallCodex,
7877
+ paths: paths.codex
7878
+ },
7879
+ windsurf: {
7880
+ install: installWindsurf,
7881
+ uninstall: uninstallWindsurf,
7882
+ paths: paths.windsurf
7883
+ },
7884
+ cline: {
7885
+ install: installCline,
7886
+ uninstall: uninstallCline,
7887
+ paths: paths.cline
7888
+ },
7889
+ kilocode: {
7890
+ install: installKilocode,
7891
+ uninstall: uninstallKilocode,
7892
+ paths: paths.kilocode
7893
+ },
7894
+ antigravity: {
7895
+ install: installAntigravity,
7896
+ uninstall: uninstallAntigravity,
7897
+ paths: paths.antigravity
7898
+ },
7899
+ copilot: {
7900
+ install: installCopilot,
7901
+ uninstall: uninstallCopilot,
7902
+ paths: paths.copilot
7903
+ }
7904
+ };
7905
+ const defaultScopeFor = (agent) => AGENTS_PROJECT_ONLY.includes(agent) ? "project" : "global";
7906
+ const detectInstalledAgents = (opts) => {
7907
+ const hits = [];
7908
+ for (const agent of ALL_AGENTS) {
7909
+ const scope = defaultScopeFor(agent);
7910
+ if (REGISTRY[agent].paths({
7911
+ home: opts.home,
7912
+ cwd: opts.cwd,
7913
+ scope
7914
+ }).some((p) => fs.existsSync(p))) hits.push(agent);
7025
7915
  }
7026
- const result = installClaude({ home: os.homedir() });
7027
- if (result.wrote.length === 0) {
7028
- process.stdout.write("hook install: nothing to do (already up to date)\n");
7916
+ return hits;
7917
+ };
7918
+
7919
+ //#endregion
7920
+ //#region src/commands/hook.ts
7921
+ const resolveOpts = (agent, flags) => {
7922
+ const scope = AGENTS_PROJECT_ONLY.includes(agent) ? "project" : flags.scope;
7923
+ return {
7924
+ home: os.homedir(),
7925
+ cwd: process.cwd(),
7926
+ scope,
7927
+ dryRun: flags.dryRun,
7928
+ qualityGate: flags.qualityGate
7929
+ };
7930
+ };
7931
+ const printPlan = (agent, result) => {
7932
+ if (result.planned.length === 0) {
7933
+ process.stdout.write(` ${agent}: already up to date\n`);
7029
7934
  return;
7030
7935
  }
7031
- for (const f of result.wrote) process.stdout.write(`wrote ${f}\n`);
7032
- for (const f of result.skipped) process.stdout.write(`skip ${f}\n`);
7936
+ process.stdout.write(` ${agent}:\n`);
7937
+ for (const op of result.planned) process.stdout.write(` ${style(theme, "dim", "+")} ${op.path} — ${op.summary}\n`);
7033
7938
  };
7034
- const hookRun = async (agent) => {
7035
- if (agent !== "claude") {
7036
- process.stderr.write(`hook: agent "${agent}" not implemented yet\n`);
7939
+ const hookInstall = async (flags) => {
7940
+ if (flags.dryRun) process.stdout.write("aislop hook install (dry-run)\n\n");
7941
+ for (const agent of flags.agents) {
7942
+ const opts = resolveOpts(agent, flags);
7943
+ const result = REGISTRY[agent].install(opts);
7944
+ if (flags.dryRun) {
7945
+ printPlan(agent, result);
7946
+ continue;
7947
+ }
7948
+ if (result.wrote.length === 0) {
7949
+ process.stdout.write(`${agent}: nothing to do (already up to date)\n`);
7950
+ continue;
7951
+ }
7952
+ for (const f of result.wrote) process.stdout.write(` wrote ${f}\n`);
7953
+ for (const f of result.skipped) process.stdout.write(` skip ${f}\n`);
7954
+ }
7955
+ if (flags.dryRun) process.stdout.write("\nNo files touched. Re-run without --dry-run to apply.\n");
7956
+ };
7957
+ const hookUninstall = async (flags) => {
7958
+ if (flags.dryRun) process.stdout.write("aislop hook uninstall (dry-run)\n\n");
7959
+ for (const agent of flags.agents) {
7960
+ const opts = resolveOpts(agent, flags);
7961
+ const result = REGISTRY[agent].uninstall(opts);
7962
+ if (result.removed.length === 0) {
7963
+ process.stdout.write(`${agent}: nothing installed\n`);
7964
+ continue;
7965
+ }
7966
+ for (const f of result.removed) process.stdout.write(` remove ${f}\n`);
7967
+ for (const f of result.skipped) process.stdout.write(` skip ${f}\n`);
7968
+ }
7969
+ };
7970
+ const hookStatus = async () => {
7971
+ const home = os.homedir();
7972
+ const cwd = process.cwd();
7973
+ process.stdout.write("aislop hook status\n\n");
7974
+ const installed = new Set(detectInstalledAgents({
7975
+ home,
7976
+ cwd
7977
+ }));
7978
+ for (const agent of ALL_AGENTS) {
7979
+ const scope = defaultScopeFor(agent);
7980
+ const hits = REGISTRY[agent].paths({
7981
+ home,
7982
+ cwd,
7983
+ scope
7984
+ }).filter((p) => fs.existsSync(p));
7985
+ const status = installed.has(agent) ? "installed" : "not installed";
7986
+ const marker = installed.has(agent) ? "✓" : "·";
7987
+ process.stdout.write(` ${marker} ${agent.padEnd(12)} ${scope.padEnd(8)} ${status}\n`);
7988
+ for (const p of hits) process.stdout.write(` ${p}\n`);
7989
+ }
7990
+ };
7991
+ const hookRun = async (agent, flags) => {
7992
+ let exitCode = 0;
7993
+ if (agent === "claude") exitCode = flags?.stop ? await runClaudeStopHook() : await runClaudeHook();
7994
+ else if (agent === "cursor") exitCode = await runCursorHook();
7995
+ else if (agent === "gemini") exitCode = await runGeminiHook();
7996
+ else {
7997
+ process.stderr.write(`hook: agent "${agent}" has no runtime adapter (rules-file-only)\n`);
7037
7998
  process.exit(0);
7038
7999
  }
7039
- const exitCode = await runClaudeHook();
7040
8000
  process.exit(exitCode);
7041
8001
  };
8002
+ const hookBaseline = async () => {
8003
+ const result = await captureBaseline(process.cwd());
8004
+ process.stdout.write(`baseline captured: score=${result.score} files=${result.fileCount}\n`);
8005
+ process.stdout.write(` -> ${result.path}\n`);
8006
+ };
8007
+ const parseAgentFlag = (raw, fallback) => {
8008
+ if (!raw) return fallback;
8009
+ const parts = raw.split(",").map((s) => s.trim()).filter(Boolean);
8010
+ const unknown = parts.filter((p) => !ALL_AGENTS.includes(p));
8011
+ if (unknown.length > 0) throw new Error(`Unknown agent(s): ${unknown.join(", ")}. Valid: ${ALL_AGENTS.join(", ")}`);
8012
+ return parts;
8013
+ };
8014
+ const defaultInstallTargets = () => {
8015
+ return AGENTS_SUPPORTING_BOTH_SCOPES;
8016
+ };
7042
8017
 
7043
8018
  //#endregion
7044
8019
  //#region src/commands/init.ts
@@ -7612,14 +8587,43 @@ program.command("rules [directory]").description("List all available rules").act
7612
8587
  await rulesCommand(directory);
7613
8588
  });
7614
8589
  const hook = program.command("hook").description("Install or invoke AI-agent integration hooks");
7615
- hook.command("install").description("Install an agent-integration hook (Claude Code supported)").option("--agent <name>", "target agent (claude)", "claude").option("-g, --global", "install to the user-scope config", true).action(async (opts) => {
8590
+ const resolveScope = (flags) => {
8591
+ if (flags.project) return "project";
8592
+ if (flags.global) return "global";
8593
+ return "global";
8594
+ };
8595
+ hook.command("install").description("Install aislop hooks for one or more coding agents").option("--agent <names>", "comma-separated agent list (claude,cursor,gemini,codex,windsurf,cline,kilocode,antigravity,copilot). default: all non-project-only agents").option("-g, --global", "install to the user-scope config (default for agents that support it)").option("--project", "install to the project-scope config").option("--dry-run", "print the planned diff without writing").option("--yes", "skip the confirmation prompt (reserved)").option("--quality-gate", "add a Stop hook that blocks when score regresses below baseline (Claude only)").action(async (opts) => {
7616
8596
  await hookInstall({
7617
- agent: opts.agent,
7618
- global: Boolean(opts.global)
8597
+ agents: parseAgentFlag(opts.agent, defaultInstallTargets()),
8598
+ scope: resolveScope(opts),
8599
+ dryRun: Boolean(opts.dryRun),
8600
+ yes: Boolean(opts.yes),
8601
+ qualityGate: Boolean(opts.qualityGate)
8602
+ });
8603
+ });
8604
+ hook.command("uninstall").description("Uninstall aislop hooks for one or more agents").option("--agent <names>", "comma-separated agent list. default: all agents with installed hooks").option("-g, --global", "uninstall from user-scope config").option("--project", "uninstall from project-scope config").option("--dry-run", "print the planned removal without writing").action(async (opts) => {
8605
+ await hookUninstall({
8606
+ agents: parseAgentFlag(opts.agent, defaultInstallTargets()),
8607
+ scope: resolveScope(opts),
8608
+ dryRun: Boolean(opts.dryRun),
8609
+ yes: true,
8610
+ qualityGate: false
7619
8611
  });
7620
8612
  });
7621
- hook.command("claude").description("Internal: Claude Code PostToolUse callback (reads stdin)").action(async () => {
7622
- await hookRun("claude");
8613
+ hook.command("status").description("Show which agent hooks are installed").action(async () => {
8614
+ await hookStatus();
8615
+ });
8616
+ hook.command("baseline").description("Capture the current project score as the quality-gate baseline").action(async () => {
8617
+ await hookBaseline();
8618
+ });
8619
+ hook.command("claude").description("Internal: Claude Code PostToolUse / Stop callback (reads stdin)").option("--stop", "run in Stop-hook mode for the quality gate").action(async (opts) => {
8620
+ await hookRun("claude", { stop: Boolean(opts.stop) });
8621
+ });
8622
+ hook.command("cursor").description("Internal: Cursor afterFileEdit callback (reads stdin)").action(async () => {
8623
+ await hookRun("cursor");
8624
+ });
8625
+ hook.command("gemini").description("Internal: Gemini CLI AfterTool callback (reads stdin)").action(async () => {
8626
+ await hookRun("gemini");
7623
8627
  });
7624
8628
  const main = async () => {
7625
8629
  await program.parseAsync();