@weareikko/code-review 0.9.5 → 0.9.7

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.
@@ -35,7 +35,7 @@ var GitlabReviewError = class extends Error {
35
35
  };
36
36
  /**
37
37
  * Patterns that identify a provider credit/quota-exhaustion error across
38
- * providers (Anthropic, OpenAI, Cloudflare AI Gateway, …). Matched against the
38
+ * providers (Anthropic, OpenAI, OpenRouter, …). Matched against the
39
39
  * provider's error message. Deliberately excludes transient rate limits (429),
40
40
  * which are retryable rather than a billing dead-end.
41
41
  */
@@ -399,7 +399,7 @@ async function git$1(args, options = {}) {
399
399
  try {
400
400
  const { stdout } = await exec$1("git", args, {
401
401
  cwd: options.cwd,
402
- maxBuffer: 50 * 1024 * 1024
402
+ maxBuffer: 52428800
403
403
  });
404
404
  return stdout;
405
405
  } catch (error) {
@@ -541,6 +541,10 @@ function summarizeDiff(diff) {
541
541
  }
542
542
  //#endregion
543
543
  //#region src/skills.ts
544
+ /** Normalize a filesystem-relative path to the POSIX form used in URLs. */
545
+ function toPosixPath(path) {
546
+ return sep === "/" ? path : path.split(sep).join("/");
547
+ }
544
548
  var SKILL_DIRS = [".agents/skills", ".claude/skills"];
545
549
  var RESOURCE_DIRS = ["references"];
546
550
  function parseFrontmatter(content) {
@@ -563,7 +567,7 @@ function parseFrontmatter(content) {
563
567
  description: trimmedDescription
564
568
  };
565
569
  }
566
- async function loadSkillFromDir(dirPath, source) {
570
+ async function loadSkillFromDir(dirPath, origin) {
567
571
  const skillMdPath = join(dirPath, "SKILL.md");
568
572
  let content;
569
573
  try {
@@ -580,14 +584,17 @@ async function loadSkillFromDir(dirPath, source) {
580
584
  filePath: skillMdPath,
581
585
  rootDir: dirPath,
582
586
  resourceDirs,
583
- source
587
+ origin
584
588
  };
585
589
  }
586
590
  function resolveBuiltinSkillsDir() {
587
591
  return join(dirname(fileURLToPath(import.meta.url)), "..", "skills");
588
592
  }
589
593
  async function loadBuiltinSkill(name) {
590
- return loadSkillFromDir(join(resolveBuiltinSkillsDir(), name), "builtin");
594
+ return loadSkillFromDir(join(resolveBuiltinSkillsDir(), name), {
595
+ kind: "builtin",
596
+ name
597
+ });
591
598
  }
592
599
  async function loadAutoDiscoveredSkills(cwd, gitRoot, warn) {
593
600
  const dirs = [];
@@ -610,7 +617,10 @@ async function loadAutoDiscoveredSkills(cwd, gitRoot, warn) {
610
617
  }
611
618
  for (const entry of entries) {
612
619
  const entryPath = join(skillsPath, entry);
613
- const skill = await loadSkillFromDir(entryPath, "project");
620
+ const skill = await loadSkillFromDir(entryPath, {
621
+ kind: "project",
622
+ path: toPosixPath(relative(gitRoot, entryPath))
623
+ });
614
624
  if (skill) found.set(skill.name, skill);
615
625
  else if (warn && existsSync(join(entryPath, "SKILL.md"))) warn(`Skill at ${entryPath} has a SKILL.md but is missing required frontmatter fields (name, description) — skill not loaded.`);
616
626
  }
@@ -781,7 +791,8 @@ function redactUrl(url) {
781
791
  }
782
792
  /** Base directory for cached git-skill clones (honours `XDG_CACHE_HOME`). */
783
793
  function resolveSkillCacheDir() {
784
- return join(process.env.XDG_CACHE_HOME?.trim() || join(homedir(), ".cache"), "code-review", "skills");
794
+ const base = process.env.XDG_CACHE_HOME?.trim() || join(homedir(), ".cache");
795
+ return join(base, "code-review", "skills");
785
796
  }
786
797
  /**
787
798
  * Stable cache-directory name for a git skill. Keyed on the clone URL plus the
@@ -898,13 +909,20 @@ async function loadNamedSkill(spec, cwd, options = {}) {
898
909
  const pkgRef = parsed.subpath ? `${parsed.packageName} (subpath "${parsed.subpath}")` : parsed.packageName;
899
910
  throw new ConfigError(`Cannot load skill: "${spec}"`, { hint: `Package ${pkgRef} was not found in node_modules. Run \`npm install ${parsed.packageName}\` in the project.` });
900
911
  }
901
- const skill = await loadSkillFromDir(dir, "npm");
912
+ const skill = await loadSkillFromDir(dir, {
913
+ kind: "npm",
914
+ packageName: parsed.packageName,
915
+ subpath: parsed.subpath
916
+ });
902
917
  if (!skill) throw new ConfigError(`Cannot load skill: "${spec}"`, { hint: `The package at ${dir} does not contain a valid SKILL.md.` });
903
918
  return skill;
904
919
  }
905
920
  if (parsed.protocol === "file") {
906
921
  const resolvedPath = parsed.path.startsWith("/") ? parsed.path : join(cwd, parsed.path);
907
- const skill = await loadSkillFromDir(resolvedPath, "file");
922
+ const skill = await loadSkillFromDir(resolvedPath, {
923
+ kind: "file",
924
+ path: resolvedPath
925
+ });
908
926
  if (!skill) throw new ConfigError(`Cannot load skill: "${spec}"`, { hint: `No valid SKILL.md was found at "${resolvedPath}". Check that the path points to a skill directory.` });
909
927
  return skill;
910
928
  }
@@ -922,7 +940,12 @@ async function loadNamedSkill(spec, cwd, options = {}) {
922
940
  hint: `Failed to clone "${redactUrl(parsed.url)}"${atRef}. Check the URL, the ref, and your git credentials. For GitLab, prefer the SSH form: git+ssh://git@host/group/project.git`
923
941
  });
924
942
  }
925
- const skill = await loadSkillFromDir(parsed.subpath ? join(repoDir, parsed.subpath) : repoDir, "git");
943
+ const skill = await loadSkillFromDir(parsed.subpath ? join(repoDir, parsed.subpath) : repoDir, {
944
+ kind: "git",
945
+ url: parsed.url,
946
+ ref: parsed.ref,
947
+ path: parsed.subpath
948
+ });
926
949
  if (!skill) throw new ConfigError(`Cannot load skill: "${spec}"`, { hint: parsed.subpath ? `The cloned repository has no valid SKILL.md at subpath "${parsed.subpath}".` : "The cloned repository has no valid SKILL.md at its root. If the skill lives in a subdirectory, point at it with \"#<ref>/<subpath>\"." });
927
950
  return skill;
928
951
  }
@@ -943,7 +966,7 @@ var DEFAULT_MARKETPLACE_FORMAT = "anthropic";
943
966
  * (`npm:`, `file:`, `git:`) or the bare-name builtin lookup, and so cannot be
944
967
  * used as a marketplace name — `<name>:<plugin>/<skill>` would be ambiguous.
945
968
  */
946
- var RESERVED_MARKETPLACE_NAMES = new Set([
969
+ var RESERVED_MARKETPLACE_NAMES = /* @__PURE__ */ new Set([
947
970
  "npm",
948
971
  "file",
949
972
  "git",
@@ -1061,7 +1084,7 @@ async function resolveAnthropicSkill(repoDir, mp, spec) {
1061
1084
  const pluginDir = resolvePluginDir(repoDir, manifest, entry.source, mp, spec);
1062
1085
  const realPluginDir = await resolveInside(realRoot, pluginDir, ref);
1063
1086
  const bases = computeSkillBases(repoDir, pluginDir, entry, entry.strict !== false && realPluginDir ? await readPluginManifest(realRoot, realPluginDir, ref) : null, ref);
1064
- const skill = await findSkillInBases(realRoot, bases, pluginDir, spec.skill, ref);
1087
+ const skill = await findSkillInBases(realRoot, bases, pluginDir, spec, ref, mp);
1065
1088
  if (!skill) throw new ConfigError(`Cannot load skill: "${ref}"`, { hint: `No skill "${spec.skill}" found in plugin "${spec.plugin}". Looked under the default skills/ directory${bases.length > 1 ? " and the plugin's custom \"skills\" paths" : ""}. Check the skill name against the marketplace.` });
1066
1089
  return skill;
1067
1090
  }
@@ -1121,14 +1144,15 @@ function resolveUnderPlugin(repoDir, pluginDir, rel, ref) {
1121
1144
  * symlinks and reject any directory that escapes the cloned marketplace before a
1122
1145
  * file is read.
1123
1146
  */
1124
- async function findSkillInBases(realRoot, bases, pluginDir, skillName, ref) {
1147
+ async function findSkillInBases(realRoot, bases, pluginDir, spec, ref, mp) {
1148
+ const skillName = spec.skill;
1125
1149
  for (const base of bases) {
1126
- const container = await loadSkillIfInside(realRoot, join(base, skillName), ref);
1150
+ const container = await loadSkillIfInside(realRoot, join(base, skillName), ref, mp, spec);
1127
1151
  if (container) return container;
1128
- const single = await loadSkillIfInside(realRoot, base, ref);
1152
+ const single = await loadSkillIfInside(realRoot, base, ref, mp, spec);
1129
1153
  if (single && single.name === skillName) return single;
1130
1154
  }
1131
- const root = await loadSkillIfInside(realRoot, pluginDir, ref);
1155
+ const root = await loadSkillIfInside(realRoot, pluginDir, ref, mp, spec);
1132
1156
  return root && root.name === skillName ? root : null;
1133
1157
  }
1134
1158
  /**
@@ -1173,11 +1197,18 @@ async function readTextInside(realRoot, filePath, ref) {
1173
1197
  * resolve within `realRoot`. Guarding the file (not just the directory) closes
1174
1198
  * the case where a legitimate in-repo dir holds a `SKILL.md` symlinked outside.
1175
1199
  */
1176
- async function loadSkillIfInside(realRoot, dir, ref) {
1200
+ async function loadSkillIfInside(realRoot, dir, ref, mp, spec) {
1177
1201
  const real = await resolveInside(realRoot, dir, ref);
1178
1202
  if (!real) return null;
1179
1203
  if (await readTextInside(realRoot, join(real, "SKILL.md"), ref) === null) return null;
1180
- return loadSkillFromDir(real, "marketplace");
1204
+ return loadSkillFromDir(real, {
1205
+ kind: "marketplace",
1206
+ marketplace: mp.name,
1207
+ plugin: spec.plugin,
1208
+ url: mp.url,
1209
+ ref: mp.ref,
1210
+ path: toPosixPath(relative(realRoot, real))
1211
+ });
1181
1212
  }
1182
1213
  /**
1183
1214
  * Resolve a plugin entry's `source` to an absolute directory inside the cloned
@@ -1304,13 +1335,24 @@ function extractExistingFingerprints(discussions) {
1304
1335
  for (const discussion of discussions) for (const note of discussion.notes ?? []) for (const match of String(note.body ?? "").matchAll(FINGERPRINT_MARKER_RE$2)) set.add(match[1]);
1305
1336
  return set;
1306
1337
  }
1338
+ //#endregion
1339
+ //#region src/product.ts
1340
+ /**
1341
+ * The published package name, shown in review footers. Independent of the review
1342
+ * platform: the same tool posts to GitLab and GitHub, so the footer identifies
1343
+ * the tool, not the backend. Kept as a single source of truth so the inline and
1344
+ * summary footers can never drift apart.
1345
+ */
1346
+ var PRODUCT_NAME = "@weareikko/code-review";
1347
+ /** Canonical project URL used in the footer's markdown link. */
1348
+ var PRODUCT_URL = "https://github.com/weareikko/code-review";
1307
1349
  /**
1308
1350
  * The `[name](url)` markdown link used verbatim in both the inline comment footer
1309
1351
  * ({@link buildCommentBody}) and the reviewed-commit summary footer
1310
1352
  * ({@link buildReviewedCommitFooter}). Changing this changes the reviewed-commit
1311
1353
  * footer format, which is guarded by a migration test.
1312
1354
  */
1313
- var PRODUCT_LINK = `[@weareikko/code-review](https://github.com/weareikko/code-review)`;
1355
+ var PRODUCT_LINK = `[${PRODUCT_NAME}](${PRODUCT_URL})`;
1314
1356
  //#endregion
1315
1357
  //#region src/posting.ts
1316
1358
  var SUMMARY_MARKER = "<!-- code-review:summary -->";
@@ -1390,7 +1432,7 @@ function buildSummaryBody(summary, costFooter, options = {}) {
1390
1432
  return `${withFooter}\n\n${buildSummaryHistoryBlock(historyEntries)}`;
1391
1433
  }
1392
1434
  function buildReviewedCommitFooter(commitSha) {
1393
- return `Reviewed by ${PRODUCT_LINK} v0.9.5 for commit ${commitSha}.`;
1435
+ return `Reviewed by ${PRODUCT_LINK} v0.9.7 for commit ${commitSha}.`;
1394
1436
  }
1395
1437
  function extractReviewedCommitSha(body) {
1396
1438
  return REVIEWED_COMMIT_FOOTER_PATTERN.exec(body)?.[1] ?? null;
@@ -1610,8 +1652,9 @@ async function createDraftsConcurrently(gitlab, project, mr, fresh) {
1610
1652
  next += 1;
1611
1653
  if (index >= fresh.length) return;
1612
1654
  const item = fresh[index];
1655
+ const draft = await gitlab.createDraftNote(project, mr, item.payload);
1613
1656
  records[index] = {
1614
- id: (await gitlab.createDraftNote(project, mr, item.payload)).id,
1657
+ id: draft.id,
1615
1658
  fingerprints: item.fingerprints
1616
1659
  };
1617
1660
  }
@@ -1742,8 +1785,8 @@ var RESERVED_ENV_SUFFIX_SET = new Set(RESERVED_ENV_SUFFIXES);
1742
1785
  * CI-wide variable of the same name.
1743
1786
  *
1744
1787
  * This lets credentials and infra vars that `@earendil-works/pi-ai` reads
1745
- * (`ANTHROPIC_API_KEY`, `CLOUDFLARE_API_KEY`, `CLOUDFLARE_ACCOUNT_ID`,
1746
- * `OLLAMA_HOST`, ambient AWS/Vertex creds, …) — and the GitLab tokens — be
1788
+ * (`ANTHROPIC_API_KEY`, `OPENROUTER_API_KEY`, `OLLAMA_HOST`,
1789
+ * ambient AWS/Vertex creds, …) — and the GitLab tokens — be
1747
1790
  * scoped under `CODE_REVIEW_` in shared CI without enumerating pi-ai's
1748
1791
  * provider list.
1749
1792
  *
@@ -1771,8 +1814,8 @@ function applyCodeReviewEnvPrefix(env = process.env) {
1771
1814
  /**
1772
1815
  * Default pi-ai's prompt-cache retention to `long` when the caller has not set
1773
1816
  * it. pi-ai reads `PI_CACHE_RETENTION` from `process.env` at request time; `long`
1774
- * asks providers that support it (e.g. OpenAI's `openai-responses` API, including
1775
- * via the Cloudflare AI Gateway) to keep the cached system-prompt prefix for up
1817
+ * asks providers that support it (e.g. OpenAI's `openai-responses` API) to keep
1818
+ * the cached system-prompt prefix for up
1776
1819
  * to 24h so reviews spaced hours apart still reuse it. It is a safe no-op for
1777
1820
  * providers/models without long-retention support (e.g. Anthropic), where it
1778
1821
  * behaves exactly like the default `short`.
@@ -1784,7 +1827,7 @@ function applyDefaultCacheRetention(env = process.env) {
1784
1827
  if (!env.PI_CACHE_RETENTION) env.PI_CACHE_RETENTION = "long";
1785
1828
  return env;
1786
1829
  }
1787
- var BOOLEAN_FLAGS = new Set([
1830
+ var BOOLEAN_FLAGS = /* @__PURE__ */ new Set([
1788
1831
  "dry-run",
1789
1832
  "no-post",
1790
1833
  "no-summary",
@@ -1795,7 +1838,7 @@ var BOOLEAN_FLAGS = new Set([
1795
1838
  "help",
1796
1839
  "version"
1797
1840
  ]);
1798
- var MULTI_FLAGS = new Set(["skill", "marketplace"]);
1841
+ var MULTI_FLAGS = /* @__PURE__ */ new Set(["skill", "marketplace"]);
1799
1842
  function parseArgs(argv) {
1800
1843
  const args = {};
1801
1844
  for (let i = 0; i < argv.length; i += 1) {
@@ -2035,6 +2078,16 @@ function detectPlatform(args, env, readEventFile = readEventFileSync) {
2035
2078
  if (hasGitLab && !hasGitHub) return "gitlab";
2036
2079
  throw new ConfigError(hasGitHub && hasGitLab ? "Ambiguous review platform: both GitHub and GitLab identifiers are present." : "Could not detect the review platform from the environment.", { hint: `Set --platform (or CODE_REVIEW_PLATFORM) to one of: ${PLATFORMS.join(", ")}.` });
2037
2080
  }
2081
+ /**
2082
+ * Web URL of the repository under review, as the CI environment reports it:
2083
+ * GitLab exposes it directly, GitHub composes it from the server and the
2084
+ * `owner/repo` slug. Returns `undefined` outside CI, where no such URL exists.
2085
+ * Shared by the config (skill source links) and the OTel attribute builder.
2086
+ */
2087
+ function resolveProjectWebUrl(env = process.env) {
2088
+ if (env.CI_PROJECT_URL) return env.CI_PROJECT_URL.replace(/\/$/, "");
2089
+ if (env.GITHUB_SERVER_URL && env.GITHUB_REPOSITORY) return `${env.GITHUB_SERVER_URL.replace(/\/$/, "")}/${env.GITHUB_REPOSITORY}`;
2090
+ }
2038
2091
  function resolveConfig(argv = process.argv.slice(2), env = process.env) {
2039
2092
  const args = parseArgs(argv);
2040
2093
  const platform = detectPlatform(args, env);
@@ -2042,6 +2095,7 @@ function resolveConfig(argv = process.argv.slice(2), env = process.env) {
2042
2095
  const token = resolveGitLabToken(args, env);
2043
2096
  const githubApiUrl = String(args.githubApiUrl ?? env.GITHUB_API_URL ?? "https://api.github.com").replace(/\/$/, "");
2044
2097
  const githubServerUrl = String(args.githubServerUrl ?? env.GITHUB_SERVER_URL ?? "https://github.com").replace(/\/$/, "");
2098
+ const githubRepository = String(args.githubRepository ?? env.GITHUB_REPOSITORY ?? "");
2045
2099
  const model = String(args.model ?? env.CODE_REVIEW_MODEL ?? "");
2046
2100
  const apiKey = String(args.apiKey ?? resolveProviderApiKey(model) ?? "");
2047
2101
  const baseUrl = String(args.baseUrl ?? first(env.CODE_REVIEW_BASE_URL, resolveOllamaBaseUrl(model, env)) ?? "");
@@ -2060,11 +2114,12 @@ function resolveConfig(argv = process.argv.slice(2), env = process.env) {
2060
2114
  gitlabUrl,
2061
2115
  gitlabToken: token.token,
2062
2116
  gitlabAuthHeader: token.header,
2063
- githubRepository: String(args.githubRepository ?? env.GITHUB_REPOSITORY ?? ""),
2117
+ githubRepository,
2064
2118
  githubPr: resolveGitHubPr(args, env),
2065
2119
  githubToken: String(args.githubToken ?? env.GITHUB_TOKEN ?? ""),
2066
2120
  githubApiUrl,
2067
2121
  githubServerUrl,
2122
+ projectWebUrl: resolveProjectWebUrl(env) ?? "",
2068
2123
  model,
2069
2124
  modelPool: resolveModelPool(args, env),
2070
2125
  minSeverity: normalizeChoice(args.minSeverity ?? env.CODE_REVIEW_MIN_SEVERITY ?? "info"),
@@ -2393,7 +2448,8 @@ function createGitTools(dir, options = {}) {
2393
2448
  dir,
2394
2449
  oid
2395
2450
  });
2396
- const diff = await diffCommits(dir, fs, commit.parent[0] ?? null, oid);
2451
+ const parent = commit.parent[0] ?? null;
2452
+ const diff = await diffCommits(dir, fs, parent, oid);
2397
2453
  return text(`commit ${oid}\nAuthor: ${commit.author.name} <${commit.author.email}>\n\n${commit.message.trim()}\n\n${diff}`);
2398
2454
  }
2399
2455
  },
@@ -2406,7 +2462,9 @@ function createGitTools(dir, options = {}) {
2406
2462
  to: Type.String({ description: "Target ref/sha." })
2407
2463
  }),
2408
2464
  async execute(_id, params) {
2409
- return text(await diffCommits(dir, fs, await resolveOid(dir, fs, params.from), await resolveOid(dir, fs, params.to)));
2465
+ const fromOid = await resolveOid(dir, fs, params.from);
2466
+ const toOid = await resolveOid(dir, fs, params.to);
2467
+ return text(await diffCommits(dir, fs, fromOid, toOid));
2410
2468
  }
2411
2469
  }
2412
2470
  ];
@@ -2572,6 +2630,80 @@ function removeAtIndex(text, start, count) {
2572
2630
  function endsWithCommaOrNewline(text) {
2573
2631
  return /[,\n][ \t\r]*$/.test(text);
2574
2632
  }
2633
+ var namedHtmlEntities = {
2634
+ "&quot;": "\"",
2635
+ "&amp;": "&",
2636
+ "&lt;": "<",
2637
+ "&gt;": ">",
2638
+ "&apos;": "'"
2639
+ };
2640
+ /**
2641
+ * Try to match an HTML entity at the start of the given fragment. The fragment
2642
+ * is a small slice of text that begins exactly at the candidate '&'. Returns the
2643
+ * decoded character and the number of characters consumed, or null when there
2644
+ * is no complete, valid entity (for example a truncated "&quot" without ';').
2645
+ */
2646
+ function matchHtmlEntity(fragment) {
2647
+ if (fragment.charAt(0) !== "&") return null;
2648
+ const semicolon = fragment.indexOf(";");
2649
+ if (semicolon === -1) return null;
2650
+ const entity = fragment.substring(0, semicolon + 1);
2651
+ const named = namedHtmlEntities[entity];
2652
+ if (named !== void 0) return {
2653
+ char: named,
2654
+ length: entity.length
2655
+ };
2656
+ if (fragment.charAt(1) === "#") {
2657
+ const body = fragment.substring(2, semicolon);
2658
+ const hex = body.charAt(0) === "x" || body.charAt(0) === "X";
2659
+ const digits = hex ? body.substring(1) : body;
2660
+ if (digits.length > 0) {
2661
+ const code = Number.parseInt(digits, hex ? 16 : 10);
2662
+ if (!Number.isNaN(code) && code >= 0 && code <= 1114111) return {
2663
+ char: String.fromCodePoint(code),
2664
+ length: entity.length
2665
+ };
2666
+ }
2667
+ }
2668
+ return null;
2669
+ }
2670
+ /**
2671
+ * Test whether a matched HTML entity decodes to a double quote character
2672
+ */
2673
+ function isDoubleQuoteEntity(match) {
2674
+ return match !== null && match.char === "\"";
2675
+ }
2676
+ /**
2677
+ * Test whether a matched HTML entity decodes to a single quote character
2678
+ */
2679
+ function isSingleQuoteEntity(match) {
2680
+ return match !== null && match.char === "'";
2681
+ }
2682
+ /**
2683
+ * Count the number of occurrences of a single character in a string
2684
+ */
2685
+ function countOccurrences(text, char) {
2686
+ let count = 0;
2687
+ for (let i = 0; i < text.length; i++) if (text.charAt(i) === char) count++;
2688
+ return count;
2689
+ }
2690
+ /**
2691
+ * Test whether `closeChar` is a closing bracket and `text` still contains an
2692
+ * unmatched opening bracket of the same kind. This indicates that the end of
2693
+ * `text` is located inside the brackets, for example the quote in
2694
+ * `"a (b") c"` is followed by `)` while `(` is still unclosed.
2695
+ *
2696
+ * Note that the (potentially expensive) counting is only performed when
2697
+ * `closeChar` actually is a closing bracket.
2698
+ */
2699
+ function isInsideUnclosedBracket(text, closeChar) {
2700
+ switch (closeChar) {
2701
+ case ")": return countOccurrences(text, "(") > countOccurrences(text, ")");
2702
+ case "]": return countOccurrences(text, "[") > countOccurrences(text, "]");
2703
+ case "}": return countOccurrences(text, "{") > countOccurrences(text, "}");
2704
+ default: return false;
2705
+ }
2706
+ }
2575
2707
  //#endregion
2576
2708
  //#region node_modules/jsonrepair/lib/esm/regular/jsonrepair.js
2577
2709
  var controlCharacters = {
@@ -2744,23 +2876,26 @@ function jsonrepair(text) {
2744
2876
  processedComma = parseCharacter(",");
2745
2877
  if (!processedComma) output = insertBeforeLastWhitespace(output, ",");
2746
2878
  parseWhitespaceAndSkipComments();
2747
- } else {
2748
- processedComma = true;
2749
- initial = false;
2750
- }
2879
+ } else processedComma = true;
2751
2880
  skipEllipsis();
2752
2881
  if (!(parseString() || parseUnquotedString(true))) {
2753
- if (text[i] === "}" || text[i] === "{" || text[i] === "]" || text[i] === "[" || text[i] === void 0) output = stripLastOccurrence(output, ",");
2754
- else throwObjectKeyExpected();
2882
+ if (text[i] === "}" || text[i] === "{" || text[i] === "]" || text[i] === "[" || text[i] === void 0) {
2883
+ if (!initial) output = stripLastOccurrence(output, ",");
2884
+ } else throwObjectKeyExpected();
2755
2885
  break;
2756
2886
  }
2757
2887
  parseWhitespaceAndSkipComments();
2758
2888
  const processedColon = parseCharacter(":");
2759
2889
  const truncatedText = i >= text.length;
2760
- if (!processedColon) if (isStartOfValue(text[i]) || truncatedText) output = insertBeforeLastWhitespace(output, ":");
2761
- else throwColonExpected();
2762
- if (!parseValue()) if (processedColon || truncatedText) output += "null";
2763
- else throwColonExpected();
2890
+ if (!processedColon) {
2891
+ if (isStartOfValue(text[i]) || truncatedText) output = insertBeforeLastWhitespace(output, ":");
2892
+ else throwColonExpected();
2893
+ }
2894
+ if (!parseValue()) {
2895
+ if (processedColon || truncatedText) output += "null";
2896
+ else throwColonExpected();
2897
+ }
2898
+ initial = false;
2764
2899
  }
2765
2900
  if (text[i] === "}") {
2766
2901
  output += "}";
@@ -2783,12 +2918,13 @@ function jsonrepair(text) {
2783
2918
  while (i < text.length && text[i] !== "]") {
2784
2919
  if (!initial) {
2785
2920
  if (!parseCharacter(",")) output = insertBeforeLastWhitespace(output, ",");
2786
- } else initial = false;
2921
+ }
2787
2922
  skipEllipsis();
2788
2923
  if (!parseValue()) {
2789
- output = stripLastOccurrence(output, ",");
2924
+ if (!initial) output = stripLastOccurrence(output, ",");
2790
2925
  break;
2791
2926
  }
2927
+ initial = false;
2792
2928
  }
2793
2929
  if (text[i] === "]") {
2794
2930
  output += "]";
@@ -2830,17 +2966,19 @@ function jsonrepair(text) {
2830
2966
  function parseString() {
2831
2967
  let stopAtDelimiter = arguments.length > 0 && arguments[0] !== void 0 ? arguments[0] : false;
2832
2968
  let stopAtIndex = arguments.length > 1 && arguments[1] !== void 0 ? arguments[1] : -1;
2833
- let skipEscapeChars = text[i] === "\\";
2969
+ const skipEscapeChars = text[i] === "\\";
2834
2970
  if (skipEscapeChars) {
2835
2971
  i++;
2836
- skipEscapeChars = true;
2972
+ if (!isQuote(text[i])) throwUnexpectedCharacter();
2837
2973
  }
2838
- if (isQuote(text[i])) {
2974
+ const openEntity = text[i] === "&" ? matchHtmlEntity(text.slice(i, i + 12)) : null;
2975
+ const openedByEntity = isDoubleQuoteEntity(openEntity) || isSingleQuoteEntity(openEntity);
2976
+ if (isQuote(text[i]) || openedByEntity) {
2839
2977
  const isEndQuote = isDoubleQuote(text[i]) ? isDoubleQuote : isSingleQuote(text[i]) ? isSingleQuote : isSingleQuoteLike(text[i]) ? isSingleQuoteLike : isDoubleQuoteLike;
2840
2978
  const iBefore = i;
2841
2979
  const oBefore = output.length;
2842
2980
  let str = "\"";
2843
- i++;
2981
+ i += openedByEntity && openEntity ? openEntity.length : 1;
2844
2982
  while (true) {
2845
2983
  if (i >= text.length) {
2846
2984
  const iPrev = prevNonWhitespaceIndex(i - 1);
@@ -2858,17 +2996,19 @@ function jsonrepair(text) {
2858
2996
  output += str;
2859
2997
  return true;
2860
2998
  }
2861
- if (isEndQuote(text[i])) {
2999
+ const entity = openedByEntity && text[i] === "&" ? matchHtmlEntity(text.slice(i, i + 12)) : null;
3000
+ if (entity && openEntity ? entity.char === openEntity.char : isEndQuote(text[i])) {
2862
3001
  const iQuote = i;
2863
3002
  const oQuote = str.length;
2864
3003
  str += "\"";
2865
- i++;
3004
+ i += entity ? entity.length : 1;
2866
3005
  output += str;
2867
3006
  parseWhitespaceAndSkipComments(false);
2868
- if (stopAtDelimiter || i >= text.length || isDelimiter(text[i]) || isQuote(text[i]) || isDigit(text[i])) {
3007
+ if (stopAtDelimiter || i >= text.length || isDelimiter(text[i]) && !isInsideUnclosedBracket(str, text[i]) || isQuote(text[i]) && !nextQuoteIsEndQuote(i) || isDigit(text[i])) {
2869
3008
  parseConcatenatedString();
2870
3009
  return true;
2871
3010
  }
3011
+ if (text[i] === "\\") throwUnexpectedCharacter();
2872
3012
  const iPrevChar = prevNonWhitespaceIndex(iQuote - 1);
2873
3013
  const prevChar = text.charAt(iPrevChar);
2874
3014
  if (prevChar === ",") {
@@ -2882,7 +3022,7 @@ function jsonrepair(text) {
2882
3022
  return parseString(true);
2883
3023
  }
2884
3024
  output = output.substring(0, oBefore);
2885
- i = iQuote + 1;
3025
+ i = iQuote + (entity ? entity.length : 1);
2886
3026
  str = `${str.substring(0, oQuote)}\\${str.substring(oQuote)}`;
2887
3027
  } else if (stopAtDelimiter && isUnquotedStringDelimiter(text[i])) {
2888
3028
  if (text[i - 1] === ":" && regexUrlStart.test(text.substring(iBefore + 1, i + 2))) while (i < text.length && regexUrlChar.test(text[i])) {
@@ -2893,6 +3033,12 @@ function jsonrepair(text) {
2893
3033
  output += str;
2894
3034
  parseConcatenatedString();
2895
3035
  return true;
3036
+ } else if (entity) {
3037
+ const char = entity.char;
3038
+ if (char === "\"") str += "\\\"";
3039
+ else if (isControlCharacter(char)) str += controlCharacters[char];
3040
+ else str += char;
3041
+ i += entity.length;
2896
3042
  } else if (text[i] === "\\") {
2897
3043
  const char = text.charAt(i + 1);
2898
3044
  if (escapeCharacters[char] !== void 0) {
@@ -2954,51 +3100,48 @@ function jsonrepair(text) {
2954
3100
  */
2955
3101
  function parseNumber() {
2956
3102
  const start = i;
3103
+ let num = "";
3104
+ let invalid = false;
2957
3105
  if (text[i] === "-") {
3106
+ num += text[i];
3107
+ i++;
3108
+ if (!isDigit(text[i]) && atEndOfNumber()) num += "0";
3109
+ }
3110
+ if (text[i] === "0" && isDigit(text[i + 1])) invalid = true;
3111
+ while (isDigit(text[i])) {
3112
+ num += text[i];
2958
3113
  i++;
2959
- if (atEndOfNumber()) {
2960
- repairNumberEndingWithNumericSymbol(start);
2961
- return true;
2962
- }
2963
- if (!isDigit(text[i])) {
2964
- i = start;
2965
- return false;
2966
- }
2967
3114
  }
2968
- while (isDigit(text[i])) i++;
2969
3115
  if (text[i] === ".") {
3116
+ if (num === "" || num === "-") num += "0";
3117
+ num += text[i];
2970
3118
  i++;
2971
- if (atEndOfNumber()) {
2972
- repairNumberEndingWithNumericSymbol(start);
2973
- return true;
2974
- }
2975
- if (!isDigit(text[i])) {
2976
- i = start;
2977
- return false;
3119
+ if (!isDigit(text[i])) num += "0";
3120
+ while (isDigit(text[i])) {
3121
+ num += text[i];
3122
+ i++;
2978
3123
  }
2979
- while (isDigit(text[i])) i++;
2980
3124
  }
2981
- if (text[i] === "e" || text[i] === "E") {
2982
- i++;
2983
- if (text[i] === "-" || text[i] === "+") i++;
2984
- if (atEndOfNumber()) {
2985
- repairNumberEndingWithNumericSymbol(start);
2986
- return true;
3125
+ if (i > start) {
3126
+ if (text[i] === "e" || text[i] === "E") {
3127
+ if (num === "-") invalid = true;
3128
+ num += text[i];
3129
+ i++;
3130
+ if (text[i] === "-" || text[i] === "+") {
3131
+ num += text[i];
3132
+ i++;
3133
+ }
3134
+ if (!isDigit(text[i])) num += "0";
3135
+ while (isDigit(text[i])) {
3136
+ num += text[i];
3137
+ i++;
3138
+ }
2987
3139
  }
2988
- if (!isDigit(text[i])) {
3140
+ if (!atEndOfNumber()) {
2989
3141
  i = start;
2990
3142
  return false;
2991
3143
  }
2992
- while (isDigit(text[i])) i++;
2993
- }
2994
- if (!atEndOfNumber()) {
2995
- i = start;
2996
- return false;
2997
- }
2998
- if (i > start) {
2999
- const num = text.slice(start, i);
3000
- const hasInvalidLeadingZero = /^0\d/.test(num);
3001
- output += hasInvalidLeadingZero ? `"${num}"` : num;
3144
+ output += invalid ? `"${text.substring(start, i)}"` : num;
3002
3145
  return true;
3003
3146
  }
3004
3147
  return false;
@@ -3011,7 +3154,7 @@ function jsonrepair(text) {
3011
3154
  return parseKeyword("true", "true") || parseKeyword("false", "false") || parseKeyword("null", "null") || parseKeyword("True", "true") || parseKeyword("False", "false") || parseKeyword("None", "null");
3012
3155
  }
3013
3156
  function parseKeyword(name, value) {
3014
- if (text.slice(i, i + name.length) === name) {
3157
+ if (text.slice(i, i + name.length) === name && !isFunctionNameChar(text[i + name.length])) {
3015
3158
  output += value;
3016
3159
  i += name.length;
3017
3160
  return true;
@@ -3064,12 +3207,14 @@ function jsonrepair(text) {
3064
3207
  while (prev > 0 && isWhitespace(text, prev)) prev--;
3065
3208
  return prev;
3066
3209
  }
3210
+ function nextQuoteIsEndQuote(index) {
3211
+ let next = index + 1;
3212
+ while (next < text.length && isWhitespace(text, next)) next++;
3213
+ return next >= text.length || isDelimiter(text[next]);
3214
+ }
3067
3215
  function atEndOfNumber() {
3068
3216
  return i >= text.length || isDelimiter(text[i]) || isWhitespace(text, i);
3069
3217
  }
3070
- function repairNumberEndingWithNumericSymbol(start) {
3071
- output += `${text.slice(start, i)}0`;
3072
- }
3073
3218
  function throwInvalidCharacter(char) {
3074
3219
  throw new JSONRepairError(`Invalid character ${JSON.stringify(char)}`, i);
3075
3220
  }
@@ -3713,7 +3858,8 @@ function slugify(path) {
3713
3858
  */
3714
3859
  async function writeSkippedDiffs(cwd, sections) {
3715
3860
  if (sections.length === 0) return [];
3716
- await mkdir(join(cwd, SKIPPED_DIFF_DIR), { recursive: true });
3861
+ const dir = join(cwd, SKIPPED_DIFF_DIR);
3862
+ await mkdir(dir, { recursive: true });
3717
3863
  const files = [];
3718
3864
  for (const { path, section } of sections) {
3719
3865
  const relative = join(SKIPPED_DIFF_DIR, slugify(path));
@@ -3785,7 +3931,7 @@ function normalizeSubject(body) {
3785
3931
  var MAX_LINE_DELTA = 2;
3786
3932
  /** Min token-set Jaccard similarity of normalised subjects required to merge. */
3787
3933
  var SUBJECT_SIMILARITY_THRESHOLD = .6;
3788
- var STOP_WORDS = new Set([
3934
+ var STOP_WORDS = /* @__PURE__ */ new Set([
3789
3935
  "a",
3790
3936
  "an",
3791
3937
  "the",
@@ -3908,7 +4054,7 @@ function triageFindings(groups) {
3908
4054
  }
3909
4055
  //#endregion
3910
4056
  //#region src/gitlab-review.ts
3911
- var DEFAULT_REVIEW_TIMEOUT_MS = 600 * 1e3;
4057
+ var DEFAULT_REVIEW_TIMEOUT_MS = 6e5;
3912
4058
  var DEFAULT_MAX_DIFF_CHARS = 1e5;
3913
4059
  var CONVENTION_FILES = ["AGENTS.md", "CLAUDE.md"];
3914
4060
  var REVIEW_RULE_FILES = ["REVIEW.md"];
@@ -3926,7 +4072,7 @@ var NOISE_PATH_PATTERNS = [
3926
4072
  /\.d\.ts$/,
3927
4073
  /\.(js|css)\.map$/
3928
4074
  ];
3929
- var LOCKFILE_BASENAMES = new Set([
4075
+ var LOCKFILE_BASENAMES = /* @__PURE__ */ new Set([
3930
4076
  "package-lock.json",
3931
4077
  "npm-shrinkwrap.json",
3932
4078
  "yarn.lock",
@@ -3967,12 +4113,11 @@ var exec = promisify(execFile);
3967
4113
  *
3968
4114
  * pi-ai >=0.82 requires an explicit stream function (earlier versions built one
3969
4115
  * internally from model + getApiKey); `streamSimple` is the drop-in. Critically,
3970
- * 0.83 also moved cloudflare-ai-gateway base-URL substitution — the
3971
- * `{CLOUDFLARE_ACCOUNT_ID}` / `{CLOUDFLARE_GATEWAY_ID}` placeholders — from a
3972
- * direct `process.env` read to an explicit `env` on the stream options. Without
3973
- * threading `env` through, the gateway URL keeps its literal placeholders and
3974
- * every request fails with Cloudflare 401 2035 ("Invalid request path"). Passing
3975
- * `env` is harmless for providers whose base URL has no placeholders.
4116
+ * 0.83 also moved base-URL placeholder substitution from a direct `process.env`
4117
+ * read to an explicit `env` on the stream options. Providers whose base URL
4118
+ * contains `{VAR}` placeholders keep them literal — and fail every request —
4119
+ * unless `env` is threaded through. Passing `env` is harmless for providers
4120
+ * whose base URL has none.
3976
4121
  *
3977
4122
  * `stream` is injectable so the env threading can be unit-tested without a live call.
3978
4123
  */
@@ -4734,7 +4879,10 @@ async function runReview(config, options) {
4734
4879
  tokens: aggregated.tokens,
4735
4880
  cost: aggregated.cost,
4736
4881
  byModel: buildByModelUsage(aggregated),
4737
- skills: context.skills.map((s) => s.name),
4882
+ skills: context.skills.map((s) => ({
4883
+ name: s.name,
4884
+ origin: s.origin
4885
+ })),
4738
4886
  sizeNotice
4739
4887
  });
4740
4888
  let outputText;
@@ -5348,7 +5496,8 @@ async function startOtelBridge(options = {}) {
5348
5496
  createAgentTelemetry(runId) {
5349
5497
  const reviewerEntry = openByRun.get(runId)?.get(GEN_AI_PHASE);
5350
5498
  if (!reviewerEntry || reviewerEntry.closed) return void 0;
5351
- return buildAgentSubscriber(tracer, tokenUsage, operationCost, timeToFirstToken, trace.setSpan(context.active(), reviewerEntry.span), {
5499
+ const reviewerSpanCtx = trace.setSpan(context.active(), reviewerEntry.span);
5500
+ return buildAgentSubscriber(tracer, tokenUsage, operationCost, timeToFirstToken, reviewerSpanCtx, {
5352
5501
  ciAttrs,
5353
5502
  runId,
5354
5503
  configuredModel: runMeta.get(runId)?.model,
@@ -5638,7 +5787,7 @@ async function loadDefaultRuntime() {
5638
5787
  const [sdkNode, resources, semconv] = modules;
5639
5788
  const serviceResource = resources.resourceFromAttributes({
5640
5789
  [semconv.ATTR_SERVICE_NAME ?? "service.name"]: SERVICE_NAME,
5641
- [semconv.ATTR_SERVICE_VERSION ?? "service.version"]: "0.9.5"
5790
+ [semconv.ATTR_SERVICE_VERSION ?? "service.version"]: "0.9.7"
5642
5791
  });
5643
5792
  applyOtelExporterDefaults(process.env);
5644
5793
  const sdk = new sdkNode.NodeSDK({ resource: resources.defaultResource().merge(serviceResource) });
@@ -5822,7 +5971,7 @@ function buildCiSpanAttrs(env) {
5822
5971
  if (taskRunId) attrs["cicd.pipeline.task.run.id"] = taskRunId;
5823
5972
  const pipelineRunId = env.CI_PIPELINE_ID ?? env.GITHUB_RUN_ID;
5824
5973
  if (pipelineRunId) attrs["cicd.pipeline.run.id"] = pipelineRunId;
5825
- const repositoryUrl = env.CI_PROJECT_URL ?? (env.GITHUB_SERVER_URL && env.GITHUB_REPOSITORY ? `${env.GITHUB_SERVER_URL}/${env.GITHUB_REPOSITORY}` : void 0);
5974
+ const repositoryUrl = resolveProjectWebUrl(env);
5826
5975
  if (repositoryUrl) attrs["vcs.repository.url.full"] = repositoryUrl;
5827
5976
  return attrs;
5828
5977
  }
@@ -6110,7 +6259,7 @@ function boldCommentTitle(body) {
6110
6259
  */
6111
6260
  function buildCommentBody(body, commitSha, confidence) {
6112
6261
  const confidenceLine = `_Confidence: ${confidence}._`;
6113
- const footer = `<sub>Reviewed by ${PRODUCT_LINK} v0.9.5 for commit ${commitSha}.</sub>`;
6262
+ const footer = `<sub>Reviewed by ${PRODUCT_LINK} v0.9.7 for commit ${commitSha}.</sub>`;
6114
6263
  return `${boldCommentTitle(body.trim())}\n\n${confidenceLine}\n\n---\n\n${footer}`;
6115
6264
  }
6116
6265
  function buildPayload(comment, body, refs, resolved) {
@@ -6683,6 +6832,99 @@ function createPlatform(config) {
6683
6832
  return new GitLabPlatform(config);
6684
6833
  }
6685
6834
  //#endregion
6835
+ //#region src/skill-links.ts
6836
+ /**
6837
+ * Hosts that serve blobs at `/<repo>/blob/<ref>/<path>`. Everything else is
6838
+ * assumed to be GitLab (`/<repo>/-/blob/<ref>/<path>`), which covers both
6839
+ * gitlab.com and the self-hosted instances this tool mostly runs against.
6840
+ */
6841
+ var GITHUB_HOSTS = /* @__PURE__ */ new Set(["github.com", "www.github.com"]);
6842
+ /** Percent-encode each path segment while keeping the `/` separators intact. */
6843
+ function encodePath(path) {
6844
+ return path.split("/").filter(Boolean).map((segment) => encodeURIComponent(segment)).join("/");
6845
+ }
6846
+ /**
6847
+ * Build a blob URL for `path` at `ref` inside the repository served at
6848
+ * `repoWebUrl`. An empty ref resolves to `HEAD`, which both GitHub and GitLab
6849
+ * accept, so a skill pinned to a remote's default branch still links.
6850
+ */
6851
+ function blobUrl(repoWebUrl, ref, path) {
6852
+ let parsed;
6853
+ try {
6854
+ parsed = new URL(repoWebUrl);
6855
+ } catch {
6856
+ return;
6857
+ }
6858
+ return `${`${parsed.origin}${parsed.pathname.replace(/\/+$/, "")}`}/${GITHUB_HOSTS.has(parsed.host) ? "blob" : "-/blob"}/${encodeURIComponent(ref || "HEAD")}/${encodePath(path)}`;
6859
+ }
6860
+ /**
6861
+ * Convert a git clone URL to the repository's web URL: drop any transport
6862
+ * marker, embedded credentials, and the `.git` suffix, and serve it over HTTPS.
6863
+ * Accepts `https://`, `ssh://`, `git+<transport>://`, and scp-style
6864
+ * (`git@host:group/repo.git`) forms. Returns `undefined` for anything it cannot
6865
+ * parse, so an exotic remote simply yields an unlinked skill.
6866
+ */
6867
+ function gitRepoWebUrl(cloneUrl) {
6868
+ let raw = cloneUrl.trim();
6869
+ if (!raw) return void 0;
6870
+ if (raw.startsWith("git+")) raw = raw.slice(4);
6871
+ const scp = /^(?:[^@/]+@)?([^/:]+):(?!\/)(.+)$/.exec(raw);
6872
+ if (scp) raw = `ssh://${scp[1]}/${scp[2]}`;
6873
+ let parsed;
6874
+ try {
6875
+ parsed = new URL(raw);
6876
+ } catch {
6877
+ return;
6878
+ }
6879
+ if (!parsed.host) return void 0;
6880
+ const path = parsed.pathname.replace(/\.git\/?$/, "").replace(/\/+$/, "");
6881
+ if (!path || path === "/") return void 0;
6882
+ return `https://${parsed.host}${path}`;
6883
+ }
6884
+ /**
6885
+ * Resolve a link to a skill's `SKILL.md`, or `undefined` when the source is not
6886
+ * reachable from a browser (a `file:` path, or an in-repo skill on a run with no
6887
+ * CI project URL). Built-in skills link to the published tag of this package, so
6888
+ * the link always shows the skill exactly as the run used it.
6889
+ */
6890
+ function skillSourceUrl(origin, context = {}) {
6891
+ switch (origin.kind) {
6892
+ case "builtin": return blobUrl(PRODUCT_URL, "0.9.7", `skills/${origin.name}/SKILL.md`);
6893
+ case "project":
6894
+ if (!context.projectWebUrl || !context.commitSha) return void 0;
6895
+ return blobUrl(context.projectWebUrl, context.commitSha, `${origin.path}/SKILL.md`);
6896
+ case "npm": return `https://www.npmjs.com/package/${origin.packageName}`;
6897
+ case "file": return;
6898
+ case "git":
6899
+ case "marketplace": {
6900
+ const repoWebUrl = gitRepoWebUrl(origin.url);
6901
+ if (!repoWebUrl) return void 0;
6902
+ const path = origin.path ? `${origin.path}/SKILL.md` : "SKILL.md";
6903
+ return blobUrl(repoWebUrl, origin.ref, path);
6904
+ }
6905
+ }
6906
+ }
6907
+ /**
6908
+ * The name a skill is shown under in the footer. Marketplace skills carry their
6909
+ * full selector (`<marketplace>:<plugin>/<skill>`) because a bare skill name says
6910
+ * nothing about which marketplace and plugin it came from — and that selector is
6911
+ * exactly what a developer would put in `CODE_REVIEW_SKILLS` to use it. Every
6912
+ * other source shows the skill's own name.
6913
+ */
6914
+ function skillDisplayName(skill) {
6915
+ return skill.origin.kind === "marketplace" ? `${skill.origin.marketplace}:${skill.origin.plugin}/${skill.name}` : skill.name;
6916
+ }
6917
+ /**
6918
+ * Render one skill for the summary footer: a link to its source when one can be
6919
+ * resolved, otherwise the bare name. The name keeps its code span either way so
6920
+ * linked and unlinked skills read the same.
6921
+ */
6922
+ function formatSkillLink(skill, context = {}) {
6923
+ const name = skillDisplayName(skill);
6924
+ const url = skillSourceUrl(skill.origin, context);
6925
+ return url ? `[\`${name}\`](${url})` : `\`${name}\``;
6926
+ }
6927
+ //#endregion
6686
6928
  //#region src/summary-carryover.ts
6687
6929
  var FINGERPRINT_MARKER_GLOBAL_RE = new RegExp(FINGERPRINT_MARKER_PATTERN, "gi");
6688
6930
  var RISK_RANK = {
@@ -7058,7 +7300,10 @@ async function run(config, bridges) {
7058
7300
  const summaryBody = withCarriedOverFindings(parsed.summary, discussions, currentFingerprints);
7059
7301
  const result = await platform.upsertSummary(summaryBody, discussions, {
7060
7302
  costFooter: [formatUsageLine(usage), formatPerModelUsage(usage)].filter(Boolean).join("\n\n"),
7061
- skillsFooter: formatSkillsFooter(usage.skills),
7303
+ skillsFooter: formatSkillsFooter(usage.skills, {
7304
+ projectWebUrl: config.projectWebUrl,
7305
+ commitSha: refs.head_sha
7306
+ }),
7062
7307
  reviewedCommitSha: refs.head_sha,
7063
7308
  runId,
7064
7309
  sizeNotice: usage.sizeNotice
@@ -7125,9 +7370,15 @@ function zeroReviewUsage(model, thinkingLevel) {
7125
7370
  sizeNotice: { sizeSkippedFiles: [] }
7126
7371
  };
7127
7372
  }
7128
- function formatSkillsFooter(skills) {
7373
+ /**
7374
+ * The summary footer's skills line. Each skill links to its `SKILL.md` source so
7375
+ * a developer reading the review can check what the reviewer was told; skills
7376
+ * with no reachable source (a `file:` path, or an in-repo skill on a run without
7377
+ * CI project coordinates) stay as plain names.
7378
+ */
7379
+ function formatSkillsFooter(skills, context = {}) {
7129
7380
  if (skills.length === 0) return void 0;
7130
- return `Skills: ${skills.map((s) => `\`${s}\``).join(", ")}`;
7381
+ return `Skills: ${skills.map((s) => formatSkillLink(s, context)).join(", ")}`;
7131
7382
  }
7132
7383
  function formatUsageLine(usage) {
7133
7384
  const formatter = new Intl.NumberFormat("en-US");
@@ -7217,10 +7468,10 @@ async function main(argv = process.argv.slice(2)) {
7217
7468
  return;
7218
7469
  }
7219
7470
  if (argv.includes("--version") || argv.includes("-v")) {
7220
- console.log("0.9.5");
7471
+ console.log("0.9.7");
7221
7472
  return;
7222
7473
  }
7223
- process.stderr.write(`[code-review] @weareikko/code-review v0.9.5\n`);
7474
+ process.stderr.write(`[code-review] @weareikko/code-review v0.9.7\n`);
7224
7475
  assertNodeVersion();
7225
7476
  applyCodeReviewEnvPrefix();
7226
7477
  applyDefaultCacheRetention();
@@ -7241,6 +7492,6 @@ if (isDirectRun()) main().catch((error) => {
7241
7492
  process.exitCode = 1;
7242
7493
  });
7243
7494
  //#endregion
7244
- export { resolveNpmSkillDir as $, SUMMARY_MARKER as A, findExistingSummaryNoteId as B, normalizeSeverity as C, SUMMARY_HISTORY_ENTRY_START as D, SUMMARY_HISTORY_ENTRY_END as E, buildSummaryHistoryEntries as F, extractDiffHunkContext as G, stripSummaryMarker as H, extractReviewedCommitSha as I, normalizeBody as J, extractExistingFingerprints as K, extractSummaryHistoryEntries as L, buildReviewedCommitFooter as M, buildSizeNoticeBlock as N, SUMMARY_HISTORY_LIMIT as O, buildSummaryBody as P, parseSkillSpec as Q, findExistingReviewedCommitSha as R, traceDiagnosticPhase as S, SUMMARY_HISTORY_END as T, upsertSummaryNote as U, stripSummaryHistory as V, appendFingerprintMarkers as W, gitSkillCacheKey as X, sha256 as Y, loadNamedSkill as Z, DIAGNOSTIC_CHANNEL_PREFIX as _, main as a, diagnosticChannels as b, buildGeneratedComments as c, startOtelBridge as d, resolveSkillCacheDir as et, filterDiff as f, DIAGNOSTIC_CHANNEL_NAMES as g, parseReviewMarkdownWithWarnings as h, formatUsageLine as i, buildArchivedSummaryEntry as j, SUMMARY_HISTORY_START as k, buildPayload as l, parseReviewMarkdown as m, formatPerModelUsage as n, run as o, runReview as p, fingerprints as q, formatSkillsFooter as r, withHttpStamping as s, countPostedBySeverity as t, isOtelEnabled as u, createDiagnosticContext as v, toGitLabReviewSeverity as w, traceDiagnostic as x, createDiagnosticRunId as y, findExistingSummaryNote as z };
7495
+ export { normalizeBody as $, SUMMARY_HISTORY_END as A, buildSummaryHistoryEntries as B, createDiagnosticContext as C, traceDiagnosticPhase as D, traceDiagnostic as E, SUMMARY_MARKER as F, findExistingSummaryNoteId as G, extractSummaryHistoryEntries as H, buildArchivedSummaryEntry as I, upsertSummaryNote as J, stripSummaryHistory as K, buildReviewedCommitFooter as L, SUMMARY_HISTORY_ENTRY_START as M, SUMMARY_HISTORY_LIMIT as N, normalizeSeverity as O, SUMMARY_HISTORY_START as P, fingerprints as Q, buildSizeNoticeBlock as R, DIAGNOSTIC_CHANNEL_PREFIX as S, diagnosticChannels as T, findExistingReviewedCommitSha as U, extractReviewedCommitSha as V, findExistingSummaryNote as W, extractDiffHunkContext as X, appendFingerprintMarkers as Y, extractExistingFingerprints as Z, filterDiff as _, main as a, resolveSkillCacheDir as at, parseReviewMarkdownWithWarnings as b, blobUrl as c, skillDisplayName as d, sha256 as et, skillSourceUrl as f, startOtelBridge as g, isOtelEnabled as h, formatUsageLine as i, resolveNpmSkillDir as it, SUMMARY_HISTORY_ENTRY_END as j, toGitLabReviewSeverity as k, formatSkillLink as l, buildPayload as m, formatPerModelUsage as n, loadNamedSkill as nt, run as o, buildGeneratedComments as p, stripSummaryMarker as q, formatSkillsFooter as r, parseSkillSpec as rt, withHttpStamping as s, countPostedBySeverity as t, gitSkillCacheKey as tt, gitRepoWebUrl as u, runReview as v, createDiagnosticRunId as w, DIAGNOSTIC_CHANNEL_NAMES as x, parseReviewMarkdown as y, buildSummaryBody as z };
7245
7496
 
7246
- //# sourceMappingURL=cli-tIo5jQvJ.js.map
7497
+ //# sourceMappingURL=cli-m4pS6_5q.js.map