@weareikko/code-review 0.9.3 → 0.9.5

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.
@@ -1,20 +1,21 @@
1
- import { mkdir, readFile, readdir, rename, rm, unlink, writeFile } from "node:fs/promises";
2
- import { dirname, join, relative, resolve } from "node:path";
1
+ import { mkdir, readFile, readdir, realpath, rename, rm, unlink, writeFile } from "node:fs/promises";
2
+ import { dirname, join, relative, resolve, sep } from "node:path";
3
3
  import { fileURLToPath, pathToFileURL } from "node:url";
4
4
  import nodeFs, { existsSync, readFileSync } from "node:fs";
5
- import { getEnvApiKey, getModel } from "@earendil-works/pi-ai";
5
+ import { getEnvApiKey, streamSimple } from "@earendil-works/pi-ai/compat";
6
6
  import { createHash, randomUUID } from "node:crypto";
7
- import { tracingChannel } from "node:diagnostics_channel";
8
- import { performance } from "node:perf_hooks";
7
+ import { homedir } from "node:os";
8
+ import { parse } from "yaml";
9
9
  import { execFile } from "node:child_process";
10
10
  import { promisify } from "node:util";
11
+ import { tracingChannel } from "node:diagnostics_channel";
12
+ import { performance } from "node:perf_hooks";
11
13
  import { Agent } from "@earendil-works/pi-agent-core";
14
+ import { getBuiltinModel } from "@earendil-works/pi-ai/providers/all";
12
15
  import { createReadOnlyTools } from "@earendil-works/pi-coding-agent";
13
16
  import { createTwoFilesPatch } from "diff";
14
17
  import * as git from "isomorphic-git";
15
18
  import { Type } from "typebox";
16
- import { homedir } from "node:os";
17
- import { parse } from "yaml";
18
19
  import { SpanKind, SpanStatusCode, context, metrics, trace } from "@opentelemetry/api";
19
20
  import { SeverityNumber, logs } from "@opentelemetry/api-logs";
20
21
  //#region src/errors.ts
@@ -373,9 +374,838 @@ var GitHubClient = class {
373
374
  cursor = threads.pageInfo?.endCursor ?? null;
374
375
  if (!cursor) hasNext = false;
375
376
  }
376
- return settled;
377
+ return settled;
378
+ }
379
+ };
380
+ //#endregion
381
+ //#region src/git.ts
382
+ var exec$1 = promisify(execFile);
383
+ var DEFAULT_DIFF_CONTEXT = 20;
384
+ var DEFAULT_CODEQUALITY_ARTIFACTS = [
385
+ "gl-code-quality-report.json",
386
+ "codequality.json",
387
+ "codeclimate.json",
388
+ "code-quality-report.json"
389
+ ];
390
+ function gitErrorMessage(error) {
391
+ const err = error;
392
+ return [
393
+ err.message,
394
+ err.stderr,
395
+ err.stdout
396
+ ].filter(Boolean).join("\n").trim();
397
+ }
398
+ async function git$1(args, options = {}) {
399
+ try {
400
+ const { stdout } = await exec$1("git", args, {
401
+ cwd: options.cwd,
402
+ maxBuffer: 50 * 1024 * 1024
403
+ });
404
+ return stdout;
405
+ } catch (error) {
406
+ throw new GitError(`git ${args.join(" ")} failed.`, {
407
+ cause: error,
408
+ hint: gitErrorMessage(error)
409
+ });
410
+ }
411
+ }
412
+ function remoteRef(remote, branch) {
413
+ return `refs/remotes/${remote}/${branch}`;
414
+ }
415
+ function getMergeDiffArguments(targetBranch, options = {}) {
416
+ const remote = options.remote ?? "origin";
417
+ const context = options.context ?? DEFAULT_DIFF_CONTEXT;
418
+ return [
419
+ `${remoteRef(remote, targetBranch)}...HEAD`,
420
+ `--unified=${context}`,
421
+ "--"
422
+ ];
423
+ }
424
+ /**
425
+ * Full git argv for the merge diff. `-c core.quotepath=false` keeps non-ASCII
426
+ * paths literal instead of git's default octal-escaped + double-quoted form
427
+ * (`"a/caf\303\251.ts"`), which the comment-position parser cannot match —
428
+ * leaving such comments with invalid one-sided positions that 500 on
429
+ * `bulk_publish`. Diffing literally also gives the reviewer readable paths.
430
+ */
431
+ function getMergeDiffCommand(targetBranch, options = {}) {
432
+ return [
433
+ "-c",
434
+ "core.quotepath=false",
435
+ "diff",
436
+ ...getMergeDiffArguments(targetBranch, options)
437
+ ];
438
+ }
439
+ async function fetchBranch(remote, branch, options) {
440
+ await git$1([
441
+ "fetch",
442
+ "--no-tags",
443
+ remote,
444
+ `+refs/heads/${branch}:${remoteRef(remote, branch)}`
445
+ ], options);
446
+ }
447
+ async function isTracked(path, options) {
448
+ try {
449
+ await git$1([
450
+ "ls-files",
451
+ "--error-unmatch",
452
+ "--",
453
+ path
454
+ ], options);
455
+ return true;
456
+ } catch {
457
+ return false;
458
+ }
459
+ }
460
+ async function removeGeneratedCodeQualityArtifacts(paths = DEFAULT_CODEQUALITY_ARTIFACTS, options = {}) {
461
+ const removed = [];
462
+ for (const path of paths) {
463
+ if (await isTracked(path, options)) continue;
464
+ try {
465
+ await unlink(options.cwd ? join(options.cwd, path) : path);
466
+ removed.push(path);
467
+ } catch (error) {
468
+ if (error.code !== "ENOENT") throw error;
469
+ }
470
+ }
471
+ return removed;
472
+ }
473
+ async function prepareGitHistory(sourceBranch, targetBranch, options = {}) {
474
+ const remote = options.remote ?? "origin";
475
+ await removeGeneratedCodeQualityArtifacts(options.codeQualityArtifacts, options);
476
+ await git$1([
477
+ "fetch",
478
+ "--unshallow",
479
+ "--no-tags",
480
+ remote
481
+ ], options).catch(() => void 0);
482
+ const fetchErrors = [];
483
+ for (const branch of [targetBranch, sourceBranch]) try {
484
+ await fetchBranch(remote, branch, options);
485
+ } catch (error) {
486
+ fetchErrors.push(`${branch}: ${gitErrorMessage(error)}`);
487
+ }
488
+ if (fetchErrors.length === 2) throw new GitError(`Unable to fetch MR source/target branches from ${remote}.`, { hint: fetchErrors.join("\n") });
489
+ try {
490
+ await git$1([
491
+ "merge-base",
492
+ remoteRef(remote, targetBranch),
493
+ "HEAD"
494
+ ], options);
495
+ } catch (error) {
496
+ const fetchDetail = fetchErrors.length > 0 ? `\nFetch warnings:\n${fetchErrors.join("\n")}` : "";
497
+ throw new GitError(`Unable to prepare Git history for MR review: merge-base ${remoteRef(remote, targetBranch)} HEAD failed.`, {
498
+ cause: error,
499
+ hint: `Set GIT_DEPTH: 0 or ensure ${remote}/${targetBranch} is fetchable.${fetchDetail}\n${gitErrorMessage(error)}`
500
+ });
501
+ }
502
+ }
503
+ async function getMergeDiff(targetBranch, options = {}) {
504
+ return git$1(getMergeDiffCommand(targetBranch, options), options);
505
+ }
506
+ function getMergeCommitLogArguments(targetBranch, options = {}) {
507
+ return [
508
+ `${remoteRef(options.remote ?? "origin", targetBranch)}...HEAD`,
509
+ "--pretty=tformat:commit %h%nAuthor: %an%nDate: %as%n%n%s%n%n%b",
510
+ "--reverse",
511
+ "--no-merges"
512
+ ];
513
+ }
514
+ async function getMergeCommitLog(targetBranch, options = {}) {
515
+ return git$1(["log", ...getMergeCommitLogArguments(targetBranch, options)], options);
516
+ }
517
+ /**
518
+ * Summarize a unified diff into file/line counts for telemetry. Counts one file
519
+ * per `diff --git` header and counts `+`/`-` lines only inside a hunk (after a
520
+ * `@@` header), so the `--- a/file` / `+++ b/file` header lines are excluded and
521
+ * a genuine content line whose text starts with `++`/`--` is still counted. Pure
522
+ * and allocation-light so it can run on the full merge diff without an extra git
523
+ * invocation.
524
+ */
525
+ function summarizeDiff(diff) {
526
+ let filesChanged = 0;
527
+ let linesAdded = 0;
528
+ let linesRemoved = 0;
529
+ let inHunk = false;
530
+ for (const line of diff.split("\n")) if (line.startsWith("diff --git ")) {
531
+ filesChanged += 1;
532
+ inHunk = false;
533
+ } else if (line.startsWith("@@ ")) inHunk = true;
534
+ else if (inHunk && line.startsWith("+")) linesAdded += 1;
535
+ else if (inHunk && line.startsWith("-")) linesRemoved += 1;
536
+ return {
537
+ filesChanged,
538
+ linesAdded,
539
+ linesRemoved
540
+ };
541
+ }
542
+ //#endregion
543
+ //#region src/skills.ts
544
+ var SKILL_DIRS = [".agents/skills", ".claude/skills"];
545
+ var RESOURCE_DIRS = ["references"];
546
+ function parseFrontmatter(content) {
547
+ const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
548
+ if (!match) return null;
549
+ let data;
550
+ try {
551
+ data = parse(match[1]);
552
+ } catch {
553
+ return null;
554
+ }
555
+ if (!data || typeof data !== "object") return null;
556
+ const { name, description } = data;
557
+ if (typeof name !== "string" || typeof description !== "string") return null;
558
+ const trimmedName = name.trim();
559
+ const trimmedDescription = description.trim();
560
+ if (!trimmedName || !trimmedDescription) return null;
561
+ return {
562
+ name: trimmedName,
563
+ description: trimmedDescription
564
+ };
565
+ }
566
+ async function loadSkillFromDir(dirPath, source) {
567
+ const skillMdPath = join(dirPath, "SKILL.md");
568
+ let content;
569
+ try {
570
+ content = await readFile(skillMdPath, "utf8");
571
+ } catch {
572
+ return null;
573
+ }
574
+ const parsed = parseFrontmatter(content);
575
+ if (!parsed) return null;
576
+ const resourceDirs = RESOURCE_DIRS.filter((d) => existsSync(join(dirPath, d)));
577
+ return {
578
+ name: parsed.name,
579
+ description: parsed.description,
580
+ filePath: skillMdPath,
581
+ rootDir: dirPath,
582
+ resourceDirs,
583
+ source
584
+ };
585
+ }
586
+ function resolveBuiltinSkillsDir() {
587
+ return join(dirname(fileURLToPath(import.meta.url)), "..", "skills");
588
+ }
589
+ async function loadBuiltinSkill(name) {
590
+ return loadSkillFromDir(join(resolveBuiltinSkillsDir(), name), "builtin");
591
+ }
592
+ async function loadAutoDiscoveredSkills(cwd, gitRoot, warn) {
593
+ const dirs = [];
594
+ let current = cwd;
595
+ while (true) {
596
+ dirs.unshift(current);
597
+ if (current === gitRoot) break;
598
+ const parent = dirname(current);
599
+ if (parent === current) break;
600
+ current = parent;
601
+ }
602
+ const found = /* @__PURE__ */ new Map();
603
+ for (const dir of dirs) for (const skillDir of SKILL_DIRS) {
604
+ const skillsPath = join(dir, skillDir);
605
+ let entries;
606
+ try {
607
+ entries = await readdir(skillsPath);
608
+ } catch {
609
+ continue;
610
+ }
611
+ for (const entry of entries) {
612
+ const entryPath = join(skillsPath, entry);
613
+ const skill = await loadSkillFromDir(entryPath, "project");
614
+ if (skill) found.set(skill.name, skill);
615
+ 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
+ }
617
+ }
618
+ return [...found.values()];
619
+ }
620
+ /**
621
+ * Parse a skill spec string into a typed `SkillSpec` descriptor.
622
+ *
623
+ * Supported spec formats:
624
+ *
625
+ * | Input | Result |
626
+ * |------------------------------------|---------------------------------------------------|
627
+ * | `code-review` | `{ protocol: 'builtin', name: 'code-review' }` |
628
+ * | `npm:my-skill` | `{ protocol: 'npm', packageName: 'my-skill', ... }`|
629
+ * | `npm:@scope/pkg` | `{ protocol: 'npm', packageName: '@scope/pkg', ... }`|
630
+ * | `npm:@scope/bundle/security` | `{ protocol: 'npm', packageName: '@scope/bundle', subpath: 'security' }`|
631
+ * | `npm:bundle/security` | `{ protocol: 'npm', packageName: 'bundle', subpath: 'security' }`|
632
+ * | `file:./path/to/skill` | `{ protocol: 'file', path: './path/to/skill' }` |
633
+ * | `file:/absolute/path` | `{ protocol: 'file', path: '/absolute/path' }` |
634
+ * | `git:https://host/org/s.git` | `{ protocol: 'git', url: 'https://host/org/s.git', ref: '', subpath: '' }` |
635
+ * | `git:https://host/org/b.git#v1/sec`| `{ protocol: 'git', url: 'https://host/org/b.git', ref: 'v1', subpath: 'sec' }` |
636
+ * | `git+ssh://git@host/org/s.git` | `{ protocol: 'git', url: 'ssh://git@host/org/s.git', ref: '', subpath: '' }` |
637
+ * | `acme:dev/aria-apg` (acme known) | `{ protocol: 'marketplace', marketplace: 'acme', plugin: 'dev', skill: 'aria-apg' }` |
638
+ *
639
+ * A spec whose portion before the first `:` matches a registered marketplace
640
+ * name (from `knownMarketplaces`) is parsed as a `marketplace` reference. The
641
+ * remainder must be `<plugin>/<skill>` — an explicit skill is always required.
642
+ */
643
+ function parseSkillSpec(spec, knownMarketplaces = /* @__PURE__ */ new Set()) {
644
+ if (spec.startsWith("file:")) return {
645
+ protocol: "file",
646
+ path: spec.slice(5)
647
+ };
648
+ if (spec.startsWith("npm:")) {
649
+ const rest = spec.slice(4);
650
+ if (rest.startsWith("@")) {
651
+ const parts = rest.split("/");
652
+ if (parts.length < 2) return {
653
+ protocol: "npm",
654
+ packageName: rest,
655
+ subpath: ""
656
+ };
657
+ return {
658
+ protocol: "npm",
659
+ packageName: `${parts[0]}/${parts[1]}`,
660
+ subpath: parts.slice(2).join("/")
661
+ };
662
+ }
663
+ const slashIdx = rest.indexOf("/");
664
+ if (slashIdx === -1) return {
665
+ protocol: "npm",
666
+ packageName: rest,
667
+ subpath: ""
668
+ };
669
+ return {
670
+ protocol: "npm",
671
+ packageName: rest.slice(0, slashIdx),
672
+ subpath: rest.slice(slashIdx + 1)
673
+ };
674
+ }
675
+ if (spec.startsWith("git+") || spec.startsWith("git:")) return parseGitSpec(spec);
676
+ const colonIdx = spec.indexOf(":");
677
+ if (colonIdx > 0 && knownMarketplaces.has(spec.slice(0, colonIdx))) return parseMarketplaceSkillSpec(spec, colonIdx);
678
+ return {
679
+ protocol: "builtin",
680
+ name: spec
681
+ };
682
+ }
683
+ /**
684
+ * Parse the `<marketplace>:<plugin>/<skill>` selector into its parts. The
685
+ * marketplace name has already been matched against the registry by the caller;
686
+ * `colonIdx` is the index of the separating `:`. Throws a `ConfigError` when the
687
+ * `<plugin>/<skill>` remainder is malformed (a skill name is always required).
688
+ */
689
+ function parseMarketplaceSkillSpec(spec, colonIdx) {
690
+ const marketplace = spec.slice(0, colonIdx);
691
+ const rest = spec.slice(colonIdx + 1);
692
+ const slashIdx = rest.indexOf("/");
693
+ const plugin = slashIdx === -1 ? rest : rest.slice(0, slashIdx);
694
+ const skill = slashIdx === -1 ? "" : rest.slice(slashIdx + 1);
695
+ if (!plugin || !skill || skill.includes("/")) throw new ConfigError(`Cannot load skill: "${spec}"`, { hint: `Marketplace skills use the form "<marketplace>:<plugin>/<skill>", e.g. "${marketplace}:dev/aria-apg".` });
696
+ return {
697
+ protocol: "marketplace",
698
+ marketplace,
699
+ plugin,
700
+ skill
701
+ };
702
+ }
703
+ /**
704
+ * Parse a `git:` / `git+ssh:` skill spec into its URL, pinned ref, and subpath.
705
+ *
706
+ * - `git:<url>` strips the `git:` marker; what follows is the clone URL
707
+ * (e.g. `git:https://host/org/repo.git`).
708
+ * - `git+<transport>://…` strips the leading `git+`, leaving a URL git
709
+ * understands directly (`git+ssh://git@host/…` → `ssh://git@host/…`), matching
710
+ * npm's `package.json` git-dependency convention.
711
+ *
712
+ * An optional `#<ref>[/<subpath>]` fragment pins the ref (tag, branch, or
713
+ * commit) and, after the first `/`, points at a skill directory inside the repo.
714
+ */
715
+ function parseGitSpec(spec) {
716
+ const raw = spec.startsWith("git+") ? spec.slice(4) : spec.slice(4);
717
+ let parsed;
718
+ try {
719
+ parsed = new URL(raw);
720
+ } catch {
721
+ return {
722
+ protocol: "git",
723
+ url: raw,
724
+ ref: "",
725
+ subpath: ""
726
+ };
727
+ }
728
+ const fragment = parsed.hash ? parsed.hash.slice(1) : "";
729
+ parsed.hash = "";
730
+ const url = parsed.toString();
731
+ const slashIdx = fragment.indexOf("/");
732
+ if (slashIdx === -1) return {
733
+ protocol: "git",
734
+ url,
735
+ ref: fragment,
736
+ subpath: ""
737
+ };
738
+ return {
739
+ protocol: "git",
740
+ url,
741
+ ref: fragment.slice(0, slashIdx),
742
+ subpath: fragment.slice(slashIdx + 1)
743
+ };
744
+ }
745
+ /**
746
+ * Resolve the directory for an npm-installed skill by walking `node_modules`
747
+ * upward from `cwd` (supports monorepo hoisting). Returns the resolved path
748
+ * or `null` if the package / subpath cannot be found.
749
+ */
750
+ async function resolveNpmSkillDir(packageName, subpath, cwd) {
751
+ let current = cwd;
752
+ while (true) {
753
+ const candidate = subpath ? join(current, "node_modules", packageName, subpath) : join(current, "node_modules", packageName);
754
+ if (existsSync(candidate)) return candidate;
755
+ const parent = dirname(current);
756
+ if (parent === current) break;
757
+ current = parent;
758
+ }
759
+ return null;
760
+ }
761
+ /** Strip a leading `git+` transport marker (`git+ssh://…` → `ssh://…`). */
762
+ function normalizeGitUrl(raw) {
763
+ return raw.startsWith("git+") ? raw.slice(4) : raw;
764
+ }
765
+ /**
766
+ * Remove embedded credentials from a URL so it is safe to show in logs, hints,
767
+ * and errors. `https://user:token@host/path` becomes `https://***@host/path`.
768
+ * Non-URL strings are returned with a best-effort `//user@` → `//***@` scrub.
769
+ */
770
+ function redactUrl(url) {
771
+ try {
772
+ const parsed = new URL(url);
773
+ if (parsed.username || parsed.password) {
774
+ parsed.username = "***";
775
+ parsed.password = "";
776
+ }
777
+ return parsed.toString();
778
+ } catch {
779
+ return url.replace(/\/\/[^/@]+@/, "//***@");
780
+ }
781
+ }
782
+ /** Base directory for cached git-skill clones (honours `XDG_CACHE_HOME`). */
783
+ function resolveSkillCacheDir() {
784
+ return join(process.env.XDG_CACHE_HOME?.trim() || join(homedir(), ".cache"), "code-review", "skills");
785
+ }
786
+ /**
787
+ * Stable cache-directory name for a git skill. Keyed on the clone URL plus the
788
+ * pinned ref so that two refs of the same repo never share a cache entry.
789
+ */
790
+ function gitSkillCacheKey(url, ref) {
791
+ return createHash("sha256").update(`${url}#${ref}`).digest("hex").slice(0, 16);
792
+ }
793
+ /**
794
+ * Shallow-clone `url` at `ref` into `dir`. Using init + a single-ref fetch +
795
+ * `checkout FETCH_HEAD` (rather than `clone --branch`) means a branch, tag, or
796
+ * commit SHA all resolve through the same path; an empty `ref` fetches the
797
+ * remote's default branch via `HEAD`.
798
+ */
799
+ async function gitShallowClone(url, ref, dir) {
800
+ await git$1([
801
+ "init",
802
+ "--quiet",
803
+ dir
804
+ ]);
805
+ await git$1([
806
+ "remote",
807
+ "add",
808
+ "origin",
809
+ url
810
+ ], { cwd: dir });
811
+ await git$1([
812
+ "fetch",
813
+ "--depth",
814
+ "1",
815
+ "--quiet",
816
+ "origin",
817
+ ref || "HEAD"
818
+ ], { cwd: dir });
819
+ await git$1([
820
+ "checkout",
821
+ "--quiet",
822
+ "FETCH_HEAD"
823
+ ], { cwd: dir });
824
+ }
825
+ /** Monotonic counter making each clone's temp dir unique within the process. */
826
+ var cloneSeq = 0;
827
+ /**
828
+ * Resolve a git URL + ref to a local clone directory, reusing the on-disk cache
829
+ * when possible. The clone lands in a temp sibling first and is renamed into
830
+ * place atomically, so a crashed or concurrent clone never leaves a half-written
831
+ * cache entry. With `refresh`, any cached copy is discarded first. Shared by
832
+ * `git:` skills and Claude-plugin marketplaces (see `marketplaces.ts`).
833
+ */
834
+ async function cloneGitRepo(url, ref, options) {
835
+ const repoDir = join(options.cacheDir, gitSkillCacheKey(url, ref));
836
+ if (options.refresh) await rm(repoDir, {
837
+ recursive: true,
838
+ force: true
839
+ });
840
+ else if (existsSync(join(repoDir, ".git"))) return repoDir;
841
+ else if (existsSync(repoDir)) await rm(repoDir, {
842
+ recursive: true,
843
+ force: true
844
+ });
845
+ await mkdir(options.cacheDir, { recursive: true });
846
+ const tmpDir = `${repoDir}.tmp-${process.pid}-${cloneSeq += 1}`;
847
+ await rm(tmpDir, {
848
+ recursive: true,
849
+ force: true
850
+ });
851
+ try {
852
+ await gitShallowClone(url, ref, tmpDir);
853
+ try {
854
+ await rename(tmpDir, repoDir);
855
+ } catch (error) {
856
+ if (existsSync(join(repoDir, ".git"))) {
857
+ await rm(tmpDir, {
858
+ recursive: true,
859
+ force: true
860
+ });
861
+ return repoDir;
862
+ }
863
+ throw error;
864
+ }
865
+ } catch (error) {
866
+ await rm(tmpDir, {
867
+ recursive: true,
868
+ force: true
869
+ });
870
+ throw error;
871
+ }
872
+ return repoDir;
873
+ }
874
+ /**
875
+ * Load a skill by its spec string (`code-review`, `npm:@scope/pkg`, `file:./path`,
876
+ * `git:https://…`, …).
877
+ *
878
+ * Resolution order:
879
+ * 1. `builtin` — package-bundled `skills/<name>/`
880
+ * 2. `npm:` — `node_modules/<packageName>[/subpath]` walked up from `cwd`
881
+ * 3. `file:` — direct filesystem path (relative paths resolved from `cwd`)
882
+ * 4. `git:` / `git+ssh:` — shallow clone at the pinned ref, cached on disk,
883
+ * loading `SKILL.md` from the repo root or the `#<ref>/<subpath>` directory
884
+ *
885
+ * Throws a `ConfigError` if the spec cannot be resolved or the resolved
886
+ * directory does not contain a valid `SKILL.md`.
887
+ */
888
+ async function loadNamedSkill(spec, cwd, options = {}) {
889
+ const parsed = parseSkillSpec(spec);
890
+ if (parsed.protocol === "builtin") {
891
+ const skill = await loadBuiltinSkill(parsed.name);
892
+ if (!skill) throw new ConfigError(`Cannot load skill: "${spec}"`, { hint: `No built-in skill named "${parsed.name}" was found. Check the skill name, or use npm: / file: to reference external skills.` });
893
+ return skill;
894
+ }
895
+ if (parsed.protocol === "npm") {
896
+ const dir = await resolveNpmSkillDir(parsed.packageName, parsed.subpath, cwd);
897
+ if (dir === null) {
898
+ const pkgRef = parsed.subpath ? `${parsed.packageName} (subpath "${parsed.subpath}")` : parsed.packageName;
899
+ 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
+ }
901
+ const skill = await loadSkillFromDir(dir, "npm");
902
+ if (!skill) throw new ConfigError(`Cannot load skill: "${spec}"`, { hint: `The package at ${dir} does not contain a valid SKILL.md.` });
903
+ return skill;
904
+ }
905
+ if (parsed.protocol === "file") {
906
+ const resolvedPath = parsed.path.startsWith("/") ? parsed.path : join(cwd, parsed.path);
907
+ const skill = await loadSkillFromDir(resolvedPath, "file");
908
+ 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
+ return skill;
910
+ }
911
+ if (parsed.protocol === "marketplace") throw new ConfigError(`Cannot load skill: "${spec}"`, { hint: "Marketplace skills must be resolved through a registered marketplace, not loaded directly." });
912
+ let repoDir;
913
+ try {
914
+ repoDir = await cloneGitRepo(parsed.url, parsed.ref, {
915
+ cacheDir: options.cacheDir ?? resolveSkillCacheDir(),
916
+ refresh: options.refresh ?? false
917
+ });
918
+ } catch (error) {
919
+ const atRef = parsed.ref ? ` at ref "${parsed.ref}"` : "";
920
+ throw new ConfigError(`Cannot load skill: "${spec}"`, {
921
+ cause: error,
922
+ 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
+ });
924
+ }
925
+ const skill = await loadSkillFromDir(parsed.subpath ? join(repoDir, parsed.subpath) : repoDir, "git");
926
+ 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
+ return skill;
928
+ }
929
+ //#endregion
930
+ //#region src/marketplaces.ts
931
+ /**
932
+ * Marketplace manifest formats we know how to read. Different vendors ship the
933
+ * same open SKILL.md standard but package it differently: Claude Code uses a
934
+ * `.claude-plugin/marketplace.json` catalog (`anthropic`), OpenAI Codex uses a
935
+ * `.codex-plugin/` layout (a future `codex` format). Only `anthropic` is
936
+ * implemented today; the type is the extension point for the rest.
937
+ */
938
+ var MARKETPLACE_FORMATS = ["anthropic"];
939
+ /** The default format assumed when a marketplace declaration omits a prefix. */
940
+ var DEFAULT_MARKETPLACE_FORMAT = "anthropic";
941
+ /**
942
+ * Marketplace names that collide with skill-spec protocol prefixes
943
+ * (`npm:`, `file:`, `git:`) or the bare-name builtin lookup, and so cannot be
944
+ * used as a marketplace name — `<name>:<plugin>/<skill>` would be ambiguous.
945
+ */
946
+ var RESERVED_MARKETPLACE_NAMES = new Set([
947
+ "npm",
948
+ "file",
949
+ "git",
950
+ "builtin"
951
+ ]);
952
+ var NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
953
+ var FORMAT_PREFIX_PATTERN = /^([a-z][a-z0-9-]*):(?!\/\/)/;
954
+ /**
955
+ * Parse a single marketplace declaration: `<name>=<[format:]url[#ref]>`.
956
+ *
957
+ * - `acme=https://host/group/tools.git#1.0.0` → default `anthropic` format
958
+ * - `acme=anthropic:https://host/group/tools.git#1.0.0` → explicit format
959
+ * - `acme=git+ssh://git@host/group/tools.git#main` → SSH transport
960
+ *
961
+ * The `#<ref>` fragment is taken whole as the ref (branch names may contain
962
+ * `/`). Throws a `ConfigError` with an actionable hint on any malformed input.
963
+ */
964
+ function parseMarketplaceEntry(entry) {
965
+ const eqIdx = entry.indexOf("=");
966
+ if (eqIdx <= 0) throw new ConfigError(`Invalid marketplace declaration: "${redactUrl(entry)}"`, { hint: "Use <name>=<git-url>[#ref], e.g. acme=https://host/group/tools.git#1.0.0." });
967
+ const name = entry.slice(0, eqIdx).trim();
968
+ if (!NAME_PATTERN.test(name)) throw new ConfigError(`Invalid marketplace name: "${name}"`, { hint: "Names must start with a letter or digit and contain only letters, digits, \".\", \"_\", or \"-\"." });
969
+ if (RESERVED_MARKETPLACE_NAMES.has(name)) throw new ConfigError(`Marketplace name "${name}" is reserved`, { hint: `Choose another name — ${[...RESERVED_MARKETPLACE_NAMES].join(", ")} collide with skill-spec protocols.` });
970
+ let rhs = entry.slice(eqIdx + 1).trim();
971
+ let format = DEFAULT_MARKETPLACE_FORMAT;
972
+ const formatMatch = rhs.match(FORMAT_PREFIX_PATTERN);
973
+ if (formatMatch) {
974
+ const token = formatMatch[1];
975
+ if (!MARKETPLACE_FORMATS.includes(token)) throw new ConfigError(`Unknown marketplace format "${token}" in "${redactUrl(entry)}"`, { hint: `Supported formats: ${MARKETPLACE_FORMATS.join(", ")}. Omit the prefix (e.g. "${name}=https://…") to default to "${DEFAULT_MARKETPLACE_FORMAT}".` });
976
+ format = token;
977
+ rhs = rhs.slice(formatMatch[0].length);
978
+ }
979
+ let parsed;
980
+ try {
981
+ parsed = new URL(normalizeGitUrl(rhs));
982
+ } catch {
983
+ throw new ConfigError(`Invalid marketplace URL for "${name}": "${redactUrl(rhs)}"`, { hint: "Expected a git URL like https://host/group/tools.git or git+ssh://git@host/group/tools.git. scp-style (git@host:group/repo.git) is not supported — use the ssh:// form." });
984
+ }
985
+ const ref = parsed.hash ? parsed.hash.slice(1) : "";
986
+ parsed.hash = "";
987
+ return {
988
+ name,
989
+ format,
990
+ url: parsed.toString(),
991
+ ref
992
+ };
993
+ }
994
+ /**
995
+ * Build a name→ref registry from parsed marketplace declarations, rejecting
996
+ * duplicate names (a later entry silently shadowing an earlier one is a config
997
+ * bug worth surfacing).
998
+ */
999
+ function buildMarketplaceRegistry(refs) {
1000
+ const registry = /* @__PURE__ */ new Map();
1001
+ for (const ref of refs) {
1002
+ if (registry.has(ref.name)) throw new ConfigError(`Duplicate marketplace name "${ref.name}"`, { hint: "Each marketplace must be declared once. Remove the duplicate declaration." });
1003
+ registry.set(ref.name, ref);
1004
+ }
1005
+ return registry;
1006
+ }
1007
+ /**
1008
+ * Resolve a `<marketplace>:<plugin>/<skill>` reference to a loaded {@link Skill}.
1009
+ *
1010
+ * Clones the marketplace repo (reusing the shared on-disk clone cache), then
1011
+ * dispatches to the format-specific resolver. Throws a `ConfigError` with a
1012
+ * redacted, actionable hint when the marketplace, plugin, or skill cannot be
1013
+ * resolved — the caller treats that as a skip-and-warn, not a fatal error.
1014
+ */
1015
+ async function loadMarketplaceSkill(spec, registry, options = {}) {
1016
+ const ref = `${spec.marketplace}:${spec.plugin}/${spec.skill}`;
1017
+ const mp = registry.get(spec.marketplace);
1018
+ if (!mp) throw new ConfigError(`Cannot load skill: "${ref}"`, { hint: `No marketplace named "${spec.marketplace}" is registered. Declare it with CODE_REVIEW_MARKETPLACES or --marketplace.` });
1019
+ let repoDir;
1020
+ try {
1021
+ repoDir = await cloneGitRepo(mp.url, mp.ref, {
1022
+ cacheDir: options.cacheDir ?? resolveSkillCacheDir(),
1023
+ refresh: options.refresh ?? false
1024
+ });
1025
+ } catch (error) {
1026
+ const atRef = mp.ref ? ` at ref "${mp.ref}"` : "";
1027
+ throw new ConfigError(`Cannot load marketplace "${mp.name}"`, {
1028
+ cause: error,
1029
+ hint: `Failed to clone "${redactUrl(mp.url)}"${atRef}. Check the URL, the ref, and your git credentials. For private GitLab, prefer git+ssh://git@host/group/project.git.`
1030
+ });
1031
+ }
1032
+ switch (mp.format) {
1033
+ case "anthropic": return resolveAnthropicSkill(repoDir, mp, spec);
1034
+ default: {
1035
+ const exhaustive = mp.format;
1036
+ throw new ConfigError(`Unsupported marketplace format: "${String(exhaustive)}"`, { hint: `Supported formats: ${MARKETPLACE_FORMATS.join(", ")}.` });
1037
+ }
377
1038
  }
378
- };
1039
+ }
1040
+ /** Resolve a skill inside a Claude Code (`anthropic`) plugin marketplace. */
1041
+ async function resolveAnthropicSkill(repoDir, mp, spec) {
1042
+ const ref = `${mp.name}:${spec.plugin}/${spec.skill}`;
1043
+ const realRoot = await realpath(repoDir);
1044
+ const manifestRaw = await readTextInside(realRoot, join(repoDir, ".claude-plugin", "marketplace.json"), ref);
1045
+ if (manifestRaw === null) throw new ConfigError(`Marketplace "${mp.name}" is not a valid Anthropic plugin marketplace`, { hint: `Expected a readable .claude-plugin/marketplace.json at "${redactUrl(mp.url)}"${mp.ref ? ` (ref "${mp.ref}")` : ""}.` });
1046
+ let manifest;
1047
+ try {
1048
+ manifest = JSON.parse(manifestRaw);
1049
+ } catch (error) {
1050
+ throw new ConfigError(`Marketplace "${mp.name}" is not a valid Anthropic plugin marketplace`, {
1051
+ cause: error,
1052
+ hint: `.claude-plugin/marketplace.json at "${redactUrl(mp.url)}" is not valid JSON.`
1053
+ });
1054
+ }
1055
+ const plugins = Array.isArray(manifest.plugins) ? manifest.plugins : [];
1056
+ const entry = plugins.find((p) => p && p.name === spec.plugin);
1057
+ if (!entry) {
1058
+ const available = plugins.map((p) => typeof p?.name === "string" ? p.name : null).filter((n) => Boolean(n));
1059
+ throw new ConfigError(`Plugin "${spec.plugin}" not found in marketplace "${mp.name}"`, { hint: available.length ? `Available plugins: ${available.join(", ")}.` : "The marketplace lists no plugins." });
1060
+ }
1061
+ const pluginDir = resolvePluginDir(repoDir, manifest, entry.source, mp, spec);
1062
+ const realPluginDir = await resolveInside(realRoot, pluginDir, ref);
1063
+ 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);
1065
+ 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
+ return skill;
1067
+ }
1068
+ /**
1069
+ * Read a plugin's optional `.claude-plugin/plugin.json`. A missing file returns
1070
+ * `null` (the manifest is optional), but a malformed one throws a `ConfigError`
1071
+ * rather than being silently ignored — otherwise a typo would quietly drop the
1072
+ * plugin's declared `skills` paths.
1073
+ */
1074
+ async function readPluginManifest(realRoot, pluginDir, ref) {
1075
+ const raw = await readTextInside(realRoot, join(pluginDir, ".claude-plugin", "plugin.json"), ref);
1076
+ if (raw === null) return null;
1077
+ try {
1078
+ return JSON.parse(raw);
1079
+ } catch (error) {
1080
+ throw new ConfigError(`Malformed plugin.json for "${ref}"`, {
1081
+ cause: error,
1082
+ hint: "The plugin's .claude-plugin/plugin.json is not valid JSON. Fix it in the marketplace repository."
1083
+ });
1084
+ }
1085
+ }
1086
+ /** Normalize a `skills`/`commands` manifest field (string | string[]) to a path list. */
1087
+ function toPathArray(value) {
1088
+ return (typeof value === "string" ? [value] : Array.isArray(value) ? value : []).filter((v) => typeof v === "string").map((v) => v.trim()).filter(Boolean);
1089
+ }
1090
+ /**
1091
+ * Compute the directories to search for a named skill, per the plugin schema:
1092
+ * the default `<plugin>/skills/` scan plus any directories declared in the
1093
+ * `skills` field of the marketplace entry and (unless `strict: false`) the
1094
+ * plugin's own `plugin.json`. When the plugin source resolves to the marketplace
1095
+ * root and the entry lists specific `skills` subdirectories, those replace the
1096
+ * default scan rather than adding to it.
1097
+ */
1098
+ function computeSkillBases(repoDir, pluginDir, entry, pluginManifest, ref) {
1099
+ const strict = entry.strict !== false;
1100
+ const entrySkills = toPathArray(entry.skills);
1101
+ const custom = (strict ? [...entrySkills, ...toPathArray(pluginManifest?.skills)] : entrySkills).map((p) => resolveUnderPlugin(repoDir, pluginDir, p, ref));
1102
+ const bases = resolve(pluginDir) === resolve(repoDir) && entrySkills.length > 0 ? custom : [join(pluginDir, "skills"), ...custom];
1103
+ return [...new Set(bases)];
1104
+ }
1105
+ /** Resolve a plugin-relative `skills` path to an absolute dir, guarding escapes. */
1106
+ function resolveUnderPlugin(repoDir, pluginDir, rel, ref) {
1107
+ let p = rel.trim();
1108
+ if (p === "." || p === "./") p = "";
1109
+ else if (p.startsWith("./")) p = p.slice(2);
1110
+ const dir = join(pluginDir, p);
1111
+ ensureInside(repoDir, dir, ref);
1112
+ return dir;
1113
+ }
1114
+ /**
1115
+ * Find `skillName` across the candidate base directories. Each base is either a
1116
+ * container of skill sub-directories (`<base>/<skill>/SKILL.md`) or a single
1117
+ * skill directory (`<base>/SKILL.md`, matched by its frontmatter `name`). Falls
1118
+ * back to a single `SKILL.md` at the plugin root (single-skill plugins).
1119
+ *
1120
+ * Every candidate is verified with {@link loadSkillIfInside} to resolve through
1121
+ * symlinks and reject any directory that escapes the cloned marketplace before a
1122
+ * file is read.
1123
+ */
1124
+ async function findSkillInBases(realRoot, bases, pluginDir, skillName, ref) {
1125
+ for (const base of bases) {
1126
+ const container = await loadSkillIfInside(realRoot, join(base, skillName), ref);
1127
+ if (container) return container;
1128
+ const single = await loadSkillIfInside(realRoot, base, ref);
1129
+ if (single && single.name === skillName) return single;
1130
+ }
1131
+ const root = await loadSkillIfInside(realRoot, pluginDir, ref);
1132
+ return root && root.name === skillName ? root : null;
1133
+ }
1134
+ /**
1135
+ * Resolve `dir` through symlinks and confirm it stays within `realRoot`. Returns
1136
+ * the real path, or `null` when the directory does not exist (nothing to read).
1137
+ * A directory that resolves — via `..` or a symlink — outside the marketplace
1138
+ * throws a `ConfigError`, so a malicious manifest cannot reach outside the clone.
1139
+ */
1140
+ async function resolveInside(realRoot, dir, ref) {
1141
+ let real;
1142
+ try {
1143
+ real = await realpath(dir);
1144
+ } catch {
1145
+ return null;
1146
+ }
1147
+ assertInside(realRoot, real, ref);
1148
+ return real;
1149
+ }
1150
+ /** Throw if `real` (an already-resolved real path) is not within `realRoot`. */
1151
+ function assertInside(realRoot, real, ref) {
1152
+ if (real !== realRoot && !real.startsWith(realRoot + sep)) throw new ConfigError(`Refusing to load "${ref}": path escapes the marketplace repository`, { hint: "A plugin \"source\", manifest, or skill path resolved (via \"..\" or a symlink) outside the cloned marketplace." });
1153
+ }
1154
+ /**
1155
+ * Read a file only if its real path (following any symlink on the file itself,
1156
+ * not just its parent directory) stays within `realRoot`. Returns `null` when
1157
+ * the file does not exist; throws a `ConfigError` when it resolves outside the
1158
+ * marketplace, so a symlinked `SKILL.md` / manifest cannot exfiltrate an outside
1159
+ * file's contents.
1160
+ */
1161
+ async function readTextInside(realRoot, filePath, ref) {
1162
+ let real;
1163
+ try {
1164
+ real = await realpath(filePath);
1165
+ } catch {
1166
+ return null;
1167
+ }
1168
+ assertInside(realRoot, real, ref);
1169
+ return readFile(real, "utf8");
1170
+ }
1171
+ /**
1172
+ * Load a skill from `dir` only if both the directory AND its `SKILL.md` file
1173
+ * resolve within `realRoot`. Guarding the file (not just the directory) closes
1174
+ * the case where a legitimate in-repo dir holds a `SKILL.md` symlinked outside.
1175
+ */
1176
+ async function loadSkillIfInside(realRoot, dir, ref) {
1177
+ const real = await resolveInside(realRoot, dir, ref);
1178
+ if (!real) return null;
1179
+ if (await readTextInside(realRoot, join(real, "SKILL.md"), ref) === null) return null;
1180
+ return loadSkillFromDir(real, "marketplace");
1181
+ }
1182
+ /**
1183
+ * Resolve a plugin entry's `source` to an absolute directory inside the cloned
1184
+ * marketplace. Honors `metadata.pluginRoot` for bare (non-`./`) sources, per the
1185
+ * marketplace schema. Remote sources (github/url/git-subdir/npm) are rejected —
1186
+ * we only resolve plugins that live in the marketplace repository itself.
1187
+ */
1188
+ function resolvePluginDir(repoDir, manifest, source, mp, spec) {
1189
+ const ref = `${mp.name}:${spec.plugin}/${spec.skill}`;
1190
+ if (typeof source !== "string" || !source.trim()) throw new ConfigError(`Plugin "${spec.plugin}" in marketplace "${mp.name}" has no local source`, { hint: "Only plugins whose \"source\" is a relative path within the marketplace repo are supported. Remote plugin sources (github/url/git-subdir/npm) are not fetched." });
1191
+ let rel = source.trim();
1192
+ if (rel === "." || rel === "./") rel = "";
1193
+ else if (rel.startsWith("./")) rel = rel.slice(2);
1194
+ else {
1195
+ const pluginRoot = typeof manifest.metadata?.pluginRoot === "string" ? manifest.metadata.pluginRoot.trim() : "";
1196
+ const root = pluginRoot.startsWith("./") ? pluginRoot.slice(2) : pluginRoot;
1197
+ rel = root ? `${root.replace(/\/+$/, "")}/${rel}` : rel;
1198
+ }
1199
+ const dir = join(repoDir, rel);
1200
+ ensureInside(repoDir, dir, ref);
1201
+ return dir;
1202
+ }
1203
+ /** Guard against `..`/symlink escapes: `target` must stay within `root`. */
1204
+ function ensureInside(root, target, ref) {
1205
+ const rootResolved = resolve(root);
1206
+ const targetResolved = resolve(target);
1207
+ if (targetResolved !== rootResolved && !targetResolved.startsWith(rootResolved + sep)) throw new ConfigError(`Refusing to load "${ref}": path escapes the marketplace repository`, { hint: "A plugin \"source\" or skill path resolved outside the cloned marketplace. Check the marketplace manifest." });
1208
+ }
379
1209
  //#endregion
380
1210
  //#region src/fingerprints.ts
381
1211
  /**
@@ -560,7 +1390,7 @@ function buildSummaryBody(summary, costFooter, options = {}) {
560
1390
  return `${withFooter}\n\n${buildSummaryHistoryBlock(historyEntries)}`;
561
1391
  }
562
1392
  function buildReviewedCommitFooter(commitSha) {
563
- return `Reviewed by ${PRODUCT_LINK} v0.9.3 for commit ${commitSha}.`;
1393
+ return `Reviewed by ${PRODUCT_LINK} v0.9.5 for commit ${commitSha}.`;
564
1394
  }
565
1395
  function extractReviewedCommitSha(body) {
566
1396
  return REVIEWED_COMMIT_FOOTER_PATTERN.exec(body)?.[1] ?? null;
@@ -896,6 +1726,7 @@ var RESERVED_ENV_SUFFIXES = [
896
1726
  "FORCE_REVIEW",
897
1727
  "VERBOSE",
898
1728
  "SKILLS",
1729
+ "MARKETPLACES",
899
1730
  "REFRESH_SKILLS",
900
1731
  "THINKING_LEVEL"
901
1732
  ];
@@ -964,7 +1795,7 @@ var BOOLEAN_FLAGS = new Set([
964
1795
  "help",
965
1796
  "version"
966
1797
  ]);
967
- var MULTI_FLAGS = new Set(["skill"]);
1798
+ var MULTI_FLAGS = new Set(["skill", "marketplace"]);
968
1799
  function parseArgs(argv) {
969
1800
  const args = {};
970
1801
  for (let i = 0; i < argv.length; i += 1) {
@@ -1084,6 +1915,16 @@ function resolveSkills(args, env) {
1084
1915
  return [];
1085
1916
  }
1086
1917
  /**
1918
+ * Resolve marketplace declarations from `--marketplace` (repeatable, preferred)
1919
+ * or the comma-separated `CODE_REVIEW_MARKETPLACES`. Each entry is
1920
+ * `<name>=<[format:]url[#ref]>`; parsing throws a `ConfigError` on malformed
1921
+ * entries, reserved names, or unknown formats (fail fast on misconfiguration).
1922
+ */
1923
+ function resolveMarketplaces(args, env) {
1924
+ const argMarketplace = args.marketplace;
1925
+ return (Array.isArray(argMarketplace) ? argMarketplace : typeof argMarketplace === "string" && argMarketplace.length > 0 ? [argMarketplace] : (env.CODE_REVIEW_MARKETPLACES ?? "").split(",")).map((entry) => entry.trim()).filter(Boolean).map((entry) => parseMarketplaceEntry(entry));
1926
+ }
1927
+ /**
1087
1928
  * Resolve the model pool from `--model-pool` (preferred) or
1088
1929
  * `CODE_REVIEW_MODEL_POOL`. Both are comma-separated `provider/modelId` lists.
1089
1930
  * Entries are trimmed and empty entries dropped. Returns `[]` when unset, which
@@ -1248,6 +2089,7 @@ function resolveConfig(argv = process.argv.slice(2), env = process.env) {
1248
2089
  verbose: toBoolean(args.verbose) || toBoolean(env.CODE_REVIEW_VERBOSE),
1249
2090
  cwd: String(args.cwd ?? process.cwd()),
1250
2091
  skills: resolveSkills(args, env),
2092
+ marketplaces: resolveMarketplaces(args, env),
1251
2093
  refreshGitSkills: toBoolean(env.CODE_REVIEW_REFRESH_SKILLS)
1252
2094
  };
1253
2095
  }
@@ -1372,199 +2214,37 @@ function createDiagnosticContext(phase, config, runId, overrides = {}) {
1372
2214
  };
1373
2215
  }
1374
2216
  async function traceDiagnostic(channel, context, operation, secretValues = []) {
1375
- const started = performance.now();
1376
- return channel.tracePromise(async () => {
1377
- try {
1378
- return await operation(context);
1379
- } catch (error) {
1380
- context.errorInfo = toDiagnosticError(error, secretValues);
1381
- throw error;
1382
- } finally {
1383
- context.completedAt = (/* @__PURE__ */ new Date()).toISOString();
1384
- context.durationMs = Number((performance.now() - started).toFixed(3));
1385
- }
1386
- }, context);
1387
- }
1388
- function traceDiagnosticPhase(phase, config, runId, operation, overrides = {}) {
1389
- const context = createDiagnosticContext(phase, config, runId, overrides);
1390
- return traceDiagnostic(tracingChannel(`${DIAGNOSTIC_CHANNEL_PREFIX}:${phase}`), context, operation, collectSecrets(config));
1391
- }
1392
- function toDiagnosticError(error, secretValues = []) {
1393
- if (error instanceof Error) {
1394
- const code = "code" in error && typeof error.code === "string" ? error.code : void 0;
1395
- const timeout = "timeout" in error && error.timeout === true ? true : void 0;
1396
- const status = "status" in error && typeof error.status === "number" ? error.status : void 0;
1397
- return {
1398
- name: error.name,
1399
- message: scrubSecrets(error.message, secretValues),
1400
- code,
1401
- timeout,
1402
- status
1403
- };
1404
- }
1405
- return { message: scrubSecrets(String(error), secretValues) };
1406
- }
1407
- //#endregion
1408
- //#region src/git.ts
1409
- var exec$1 = promisify(execFile);
1410
- var DEFAULT_DIFF_CONTEXT = 20;
1411
- var DEFAULT_CODEQUALITY_ARTIFACTS = [
1412
- "gl-code-quality-report.json",
1413
- "codequality.json",
1414
- "codeclimate.json",
1415
- "code-quality-report.json"
1416
- ];
1417
- function gitErrorMessage(error) {
1418
- const err = error;
1419
- return [
1420
- err.message,
1421
- err.stderr,
1422
- err.stdout
1423
- ].filter(Boolean).join("\n").trim();
1424
- }
1425
- async function git$1(args, options = {}) {
1426
- try {
1427
- const { stdout } = await exec$1("git", args, {
1428
- cwd: options.cwd,
1429
- maxBuffer: 50 * 1024 * 1024
1430
- });
1431
- return stdout;
1432
- } catch (error) {
1433
- throw new GitError(`git ${args.join(" ")} failed.`, {
1434
- cause: error,
1435
- hint: gitErrorMessage(error)
1436
- });
1437
- }
1438
- }
1439
- function remoteRef(remote, branch) {
1440
- return `refs/remotes/${remote}/${branch}`;
1441
- }
1442
- function getMergeDiffArguments(targetBranch, options = {}) {
1443
- const remote = options.remote ?? "origin";
1444
- const context = options.context ?? DEFAULT_DIFF_CONTEXT;
1445
- return [
1446
- `${remoteRef(remote, targetBranch)}...HEAD`,
1447
- `--unified=${context}`,
1448
- "--"
1449
- ];
1450
- }
1451
- /**
1452
- * Full git argv for the merge diff. `-c core.quotepath=false` keeps non-ASCII
1453
- * paths literal instead of git's default octal-escaped + double-quoted form
1454
- * (`"a/caf\303\251.ts"`), which the comment-position parser cannot match —
1455
- * leaving such comments with invalid one-sided positions that 500 on
1456
- * `bulk_publish`. Diffing literally also gives the reviewer readable paths.
1457
- */
1458
- function getMergeDiffCommand(targetBranch, options = {}) {
1459
- return [
1460
- "-c",
1461
- "core.quotepath=false",
1462
- "diff",
1463
- ...getMergeDiffArguments(targetBranch, options)
1464
- ];
1465
- }
1466
- async function fetchBranch(remote, branch, options) {
1467
- await git$1([
1468
- "fetch",
1469
- "--no-tags",
1470
- remote,
1471
- `+refs/heads/${branch}:${remoteRef(remote, branch)}`
1472
- ], options);
1473
- }
1474
- async function isTracked(path, options) {
1475
- try {
1476
- await git$1([
1477
- "ls-files",
1478
- "--error-unmatch",
1479
- "--",
1480
- path
1481
- ], options);
1482
- return true;
1483
- } catch {
1484
- return false;
1485
- }
1486
- }
1487
- async function removeGeneratedCodeQualityArtifacts(paths = DEFAULT_CODEQUALITY_ARTIFACTS, options = {}) {
1488
- const removed = [];
1489
- for (const path of paths) {
1490
- if (await isTracked(path, options)) continue;
1491
- try {
1492
- await unlink(options.cwd ? join(options.cwd, path) : path);
1493
- removed.push(path);
1494
- } catch (error) {
1495
- if (error.code !== "ENOENT") throw error;
1496
- }
1497
- }
1498
- return removed;
1499
- }
1500
- async function prepareGitHistory(sourceBranch, targetBranch, options = {}) {
1501
- const remote = options.remote ?? "origin";
1502
- await removeGeneratedCodeQualityArtifacts(options.codeQualityArtifacts, options);
1503
- await git$1([
1504
- "fetch",
1505
- "--unshallow",
1506
- "--no-tags",
1507
- remote
1508
- ], options).catch(() => void 0);
1509
- const fetchErrors = [];
1510
- for (const branch of [targetBranch, sourceBranch]) try {
1511
- await fetchBranch(remote, branch, options);
1512
- } catch (error) {
1513
- fetchErrors.push(`${branch}: ${gitErrorMessage(error)}`);
1514
- }
1515
- if (fetchErrors.length === 2) throw new GitError(`Unable to fetch MR source/target branches from ${remote}.`, { hint: fetchErrors.join("\n") });
1516
- try {
1517
- await git$1([
1518
- "merge-base",
1519
- remoteRef(remote, targetBranch),
1520
- "HEAD"
1521
- ], options);
1522
- } catch (error) {
1523
- const fetchDetail = fetchErrors.length > 0 ? `\nFetch warnings:\n${fetchErrors.join("\n")}` : "";
1524
- throw new GitError(`Unable to prepare Git history for MR review: merge-base ${remoteRef(remote, targetBranch)} HEAD failed.`, {
1525
- cause: error,
1526
- hint: `Set GIT_DEPTH: 0 or ensure ${remote}/${targetBranch} is fetchable.${fetchDetail}\n${gitErrorMessage(error)}`
1527
- });
1528
- }
1529
- }
1530
- async function getMergeDiff(targetBranch, options = {}) {
1531
- return git$1(getMergeDiffCommand(targetBranch, options), options);
1532
- }
1533
- function getMergeCommitLogArguments(targetBranch, options = {}) {
1534
- return [
1535
- `${remoteRef(options.remote ?? "origin", targetBranch)}...HEAD`,
1536
- "--pretty=tformat:commit %h%nAuthor: %an%nDate: %as%n%n%s%n%n%b",
1537
- "--reverse",
1538
- "--no-merges"
1539
- ];
1540
- }
1541
- async function getMergeCommitLog(targetBranch, options = {}) {
1542
- return git$1(["log", ...getMergeCommitLogArguments(targetBranch, options)], options);
1543
- }
1544
- /**
1545
- * Summarize a unified diff into file/line counts for telemetry. Counts one file
1546
- * per `diff --git` header and counts `+`/`-` lines only inside a hunk (after a
1547
- * `@@` header), so the `--- a/file` / `+++ b/file` header lines are excluded and
1548
- * a genuine content line whose text starts with `++`/`--` is still counted. Pure
1549
- * and allocation-light so it can run on the full merge diff without an extra git
1550
- * invocation.
1551
- */
1552
- function summarizeDiff(diff) {
1553
- let filesChanged = 0;
1554
- let linesAdded = 0;
1555
- let linesRemoved = 0;
1556
- let inHunk = false;
1557
- for (const line of diff.split("\n")) if (line.startsWith("diff --git ")) {
1558
- filesChanged += 1;
1559
- inHunk = false;
1560
- } else if (line.startsWith("@@ ")) inHunk = true;
1561
- else if (inHunk && line.startsWith("+")) linesAdded += 1;
1562
- else if (inHunk && line.startsWith("-")) linesRemoved += 1;
1563
- return {
1564
- filesChanged,
1565
- linesAdded,
1566
- linesRemoved
1567
- };
2217
+ const started = performance.now();
2218
+ return channel.tracePromise(async () => {
2219
+ try {
2220
+ return await operation(context);
2221
+ } catch (error) {
2222
+ context.errorInfo = toDiagnosticError(error, secretValues);
2223
+ throw error;
2224
+ } finally {
2225
+ context.completedAt = (/* @__PURE__ */ new Date()).toISOString();
2226
+ context.durationMs = Number((performance.now() - started).toFixed(3));
2227
+ }
2228
+ }, context);
2229
+ }
2230
+ function traceDiagnosticPhase(phase, config, runId, operation, overrides = {}) {
2231
+ const context = createDiagnosticContext(phase, config, runId, overrides);
2232
+ return traceDiagnostic(tracingChannel(`${DIAGNOSTIC_CHANNEL_PREFIX}:${phase}`), context, operation, collectSecrets(config));
2233
+ }
2234
+ function toDiagnosticError(error, secretValues = []) {
2235
+ if (error instanceof Error) {
2236
+ const code = "code" in error && typeof error.code === "string" ? error.code : void 0;
2237
+ const timeout = "timeout" in error && error.timeout === true ? true : void 0;
2238
+ const status = "status" in error && typeof error.status === "number" ? error.status : void 0;
2239
+ return {
2240
+ name: error.name,
2241
+ message: scrubSecrets(error.message, secretValues),
2242
+ code,
2243
+ timeout,
2244
+ status
2245
+ };
2246
+ }
2247
+ return { message: scrubSecrets(String(error), secretValues) };
1568
2248
  }
1569
2249
  //#endregion
1570
2250
  //#region src/git-tool.ts
@@ -2920,437 +3600,102 @@ function parseReviewMarkdownWithWarnings(markdown) {
2920
3600
  };
2921
3601
  }
2922
3602
  return {
2923
- comments,
2924
- summary,
2925
- warnings,
2926
- malformed
2927
- };
2928
- }
2929
- function parseReviewMarkdown(markdown) {
2930
- return parseReviewMarkdownWithWarnings(markdown).comments;
2931
- }
2932
- //#endregion
2933
- //#region src/prior-threads.ts
2934
- var FINGERPRINT_MARKER_RE = new RegExp(FINGERPRINT_MARKER_PATTERN, "i");
2935
- /**
2936
- * Returns true when the note body contains a code-review fingerprint marker
2937
- * (current or legacy prefix).
2938
- * Used to identify notes posted by the bot without needing a getCurrentUser() call.
2939
- */
2940
- function isBotNote(note) {
2941
- return FINGERPRINT_MARKER_RE.test(note.body ?? "");
2942
- }
2943
- /**
2944
- * Parses the `+++ b/<path>` lines from a unified diff and returns the set of
2945
- * new file paths. `/dev/null` (deleted files) is excluded.
2946
- */
2947
- function extractChangedFiles(diff) {
2948
- const files = /* @__PURE__ */ new Set();
2949
- for (const line of diff.split("\n")) {
2950
- const match = line.match(/^\+\+\+ b\/(.+)$/);
2951
- if (match && match[1] !== "/dev/null") files.add(match[1]);
2952
- }
2953
- return files;
2954
- }
2955
- /**
2956
- * Returns the line number for a discussion note's position.
2957
- * Prefers the new-side line (`new_line`) then falls back to `old_line`.
2958
- */
2959
- function positionLine$1(note) {
2960
- return note.position?.new_line ?? note.position?.old_line ?? null;
2961
- }
2962
- /**
2963
- * Returns the file path for a discussion note's position.
2964
- * Prefers the new path then falls back to the old path.
2965
- */
2966
- function positionFile$1(note) {
2967
- return note.position?.new_path ?? note.position?.old_path ?? null;
2968
- }
2969
- /**
2970
- * Extracts prior review threads from existing MR discussions that are relevant
2971
- * to the current diff.
2972
- *
2973
- * A thread is included when:
2974
- * - It contains at least one bot note (identified by fingerprint marker).
2975
- * - It contains at least one non-system human reply after the bot note.
2976
- * - The thread's file appears in `changedFiles`.
2977
- *
2978
- * Resolved threads are included but marked with `resolved: true` so the
2979
- * reviewer can reference them without re-raising the concern.
2980
- */
2981
- function extractPriorThreads(discussions, changedFiles) {
2982
- const threads = [];
2983
- for (const discussion of discussions) {
2984
- const notes = discussion.notes ?? [];
2985
- const botNoteIndex = notes.findIndex(isBotNote);
2986
- if (botNoteIndex === -1) continue;
2987
- const botNote = notes[botNoteIndex];
2988
- const file = positionFile$1(botNote);
2989
- if (!file || !changedFiles.has(file)) continue;
2990
- const replies = notes.slice(botNoteIndex + 1).filter((n) => !n.system && (n.body?.trim() ?? "")).filter((n) => !isBotNote(n)).map((n) => n.body?.trim() ?? "");
2991
- if (replies.length === 0) continue;
2992
- const resolved = notes.some((n) => n.resolved === true);
2993
- threads.push({
2994
- file,
2995
- line: positionLine$1(botNote),
2996
- resolved,
2997
- botComment: normalizeBody(botNote.body ?? ""),
2998
- replies
2999
- });
3000
- }
3001
- return threads;
3002
- }
3003
- /**
3004
- * Renders a `<prior_review_feedback>` XML block from a list of prior threads.
3005
- * Returns an empty string when `threads` is empty.
3006
- */
3007
- function renderPriorThreadsBlock(threads) {
3008
- if (threads.length === 0) return "";
3009
- return `<prior_review_feedback>\n${threads.map((t) => {
3010
- return ` <thread ${[
3011
- `file="${t.file}"`,
3012
- t.line !== null ? `line="${t.line}"` : null,
3013
- `resolved="${t.resolved}"`
3014
- ].filter(Boolean).join(" ")}>\n${` <comment>${escapeXml(t.botComment)}</comment>`}\n${t.replies.map((r) => ` <reply>${escapeXml(r)}</reply>`).join("\n")}\n </thread>`;
3015
- }).join("\n")}\n</prior_review_feedback>`;
3016
- }
3017
- function escapeXml(text) {
3018
- return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&apos;");
3019
- }
3020
- //#endregion
3021
- //#region src/skills.ts
3022
- var SKILL_DIRS = [".agents/skills", ".claude/skills"];
3023
- var RESOURCE_DIRS = ["references"];
3024
- function parseFrontmatter(content) {
3025
- const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
3026
- if (!match) return null;
3027
- let data;
3028
- try {
3029
- data = parse(match[1]);
3030
- } catch {
3031
- return null;
3032
- }
3033
- if (!data || typeof data !== "object") return null;
3034
- const { name, description } = data;
3035
- if (typeof name !== "string" || typeof description !== "string") return null;
3036
- const trimmedName = name.trim();
3037
- const trimmedDescription = description.trim();
3038
- if (!trimmedName || !trimmedDescription) return null;
3039
- return {
3040
- name: trimmedName,
3041
- description: trimmedDescription
3042
- };
3043
- }
3044
- async function loadSkillFromDir(dirPath, source) {
3045
- const skillMdPath = join(dirPath, "SKILL.md");
3046
- let content;
3047
- try {
3048
- content = await readFile(skillMdPath, "utf8");
3049
- } catch {
3050
- return null;
3051
- }
3052
- const parsed = parseFrontmatter(content);
3053
- if (!parsed) return null;
3054
- const resourceDirs = RESOURCE_DIRS.filter((d) => existsSync(join(dirPath, d)));
3055
- return {
3056
- name: parsed.name,
3057
- description: parsed.description,
3058
- filePath: skillMdPath,
3059
- rootDir: dirPath,
3060
- resourceDirs,
3061
- source
3062
- };
3063
- }
3064
- function resolveBuiltinSkillsDir() {
3065
- return join(dirname(fileURLToPath(import.meta.url)), "..", "skills");
3066
- }
3067
- async function loadBuiltinSkill(name) {
3068
- return loadSkillFromDir(join(resolveBuiltinSkillsDir(), name), "builtin");
3069
- }
3070
- async function loadAutoDiscoveredSkills(cwd, gitRoot, warn) {
3071
- const dirs = [];
3072
- let current = cwd;
3073
- while (true) {
3074
- dirs.unshift(current);
3075
- if (current === gitRoot) break;
3076
- const parent = dirname(current);
3077
- if (parent === current) break;
3078
- current = parent;
3079
- }
3080
- const found = /* @__PURE__ */ new Map();
3081
- for (const dir of dirs) for (const skillDir of SKILL_DIRS) {
3082
- const skillsPath = join(dir, skillDir);
3083
- let entries;
3084
- try {
3085
- entries = await readdir(skillsPath);
3086
- } catch {
3087
- continue;
3088
- }
3089
- for (const entry of entries) {
3090
- const entryPath = join(skillsPath, entry);
3091
- const skill = await loadSkillFromDir(entryPath, "project");
3092
- if (skill) found.set(skill.name, skill);
3093
- 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.`);
3094
- }
3095
- }
3096
- return [...found.values()];
3097
- }
3098
- /**
3099
- * Parse a skill spec string into a typed `SkillSpec` descriptor.
3100
- *
3101
- * Supported spec formats:
3102
- *
3103
- * | Input | Result |
3104
- * |------------------------------------|---------------------------------------------------|
3105
- * | `code-review` | `{ protocol: 'builtin', name: 'code-review' }` |
3106
- * | `npm:my-skill` | `{ protocol: 'npm', packageName: 'my-skill', ... }`|
3107
- * | `npm:@scope/pkg` | `{ protocol: 'npm', packageName: '@scope/pkg', ... }`|
3108
- * | `npm:@scope/bundle/security` | `{ protocol: 'npm', packageName: '@scope/bundle', subpath: 'security' }`|
3109
- * | `npm:bundle/security` | `{ protocol: 'npm', packageName: 'bundle', subpath: 'security' }`|
3110
- * | `file:./path/to/skill` | `{ protocol: 'file', path: './path/to/skill' }` |
3111
- * | `file:/absolute/path` | `{ protocol: 'file', path: '/absolute/path' }` |
3112
- * | `git:https://host/org/s.git` | `{ protocol: 'git', url: 'https://host/org/s.git', ref: '', subpath: '' }` |
3113
- * | `git:https://host/org/b.git#v1/sec`| `{ protocol: 'git', url: 'https://host/org/b.git', ref: 'v1', subpath: 'sec' }` |
3114
- * | `git+ssh://git@host/org/s.git` | `{ protocol: 'git', url: 'ssh://git@host/org/s.git', ref: '', subpath: '' }` |
3115
- */
3116
- function parseSkillSpec(spec) {
3117
- if (spec.startsWith("file:")) return {
3118
- protocol: "file",
3119
- path: spec.slice(5)
3120
- };
3121
- if (spec.startsWith("npm:")) {
3122
- const rest = spec.slice(4);
3123
- if (rest.startsWith("@")) {
3124
- const parts = rest.split("/");
3125
- if (parts.length < 2) return {
3126
- protocol: "npm",
3127
- packageName: rest,
3128
- subpath: ""
3129
- };
3130
- return {
3131
- protocol: "npm",
3132
- packageName: `${parts[0]}/${parts[1]}`,
3133
- subpath: parts.slice(2).join("/")
3134
- };
3135
- }
3136
- const slashIdx = rest.indexOf("/");
3137
- if (slashIdx === -1) return {
3138
- protocol: "npm",
3139
- packageName: rest,
3140
- subpath: ""
3141
- };
3142
- return {
3143
- protocol: "npm",
3144
- packageName: rest.slice(0, slashIdx),
3145
- subpath: rest.slice(slashIdx + 1)
3146
- };
3147
- }
3148
- if (spec.startsWith("git+") || spec.startsWith("git:")) return parseGitSpec(spec);
3149
- return {
3150
- protocol: "builtin",
3151
- name: spec
3603
+ comments,
3604
+ summary,
3605
+ warnings,
3606
+ malformed
3152
3607
  };
3153
3608
  }
3609
+ function parseReviewMarkdown(markdown) {
3610
+ return parseReviewMarkdownWithWarnings(markdown).comments;
3611
+ }
3612
+ //#endregion
3613
+ //#region src/prior-threads.ts
3614
+ var FINGERPRINT_MARKER_RE = new RegExp(FINGERPRINT_MARKER_PATTERN, "i");
3154
3615
  /**
3155
- * Parse a `git:` / `git+ssh:` skill spec into its URL, pinned ref, and subpath.
3156
- *
3157
- * - `git:<url>` strips the `git:` marker; what follows is the clone URL
3158
- * (e.g. `git:https://host/org/repo.git`).
3159
- * - `git+<transport>://…` strips the leading `git+`, leaving a URL git
3160
- * understands directly (`git+ssh://git@host/…` → `ssh://git@host/…`), matching
3161
- * npm's `package.json` git-dependency convention.
3162
- *
3163
- * An optional `#<ref>[/<subpath>]` fragment pins the ref (tag, branch, or
3164
- * commit) and, after the first `/`, points at a skill directory inside the repo.
3616
+ * Returns true when the note body contains a code-review fingerprint marker
3617
+ * (current or legacy prefix).
3618
+ * Used to identify notes posted by the bot without needing a getCurrentUser() call.
3165
3619
  */
3166
- function parseGitSpec(spec) {
3167
- const raw = spec.startsWith("git+") ? spec.slice(4) : spec.slice(4);
3168
- let parsed;
3169
- try {
3170
- parsed = new URL(raw);
3171
- } catch {
3172
- return {
3173
- protocol: "git",
3174
- url: raw,
3175
- ref: "",
3176
- subpath: ""
3177
- };
3178
- }
3179
- const fragment = parsed.hash ? parsed.hash.slice(1) : "";
3180
- parsed.hash = "";
3181
- const url = parsed.toString();
3182
- const slashIdx = fragment.indexOf("/");
3183
- if (slashIdx === -1) return {
3184
- protocol: "git",
3185
- url,
3186
- ref: fragment,
3187
- subpath: ""
3188
- };
3189
- return {
3190
- protocol: "git",
3191
- url,
3192
- ref: fragment.slice(0, slashIdx),
3193
- subpath: fragment.slice(slashIdx + 1)
3194
- };
3620
+ function isBotNote(note) {
3621
+ return FINGERPRINT_MARKER_RE.test(note.body ?? "");
3195
3622
  }
3196
3623
  /**
3197
- * Resolve the directory for an npm-installed skill by walking `node_modules`
3198
- * upward from `cwd` (supports monorepo hoisting). Returns the resolved path
3199
- * or `null` if the package / subpath cannot be found.
3624
+ * Parses the `+++ b/<path>` lines from a unified diff and returns the set of
3625
+ * new file paths. `/dev/null` (deleted files) is excluded.
3200
3626
  */
3201
- async function resolveNpmSkillDir(packageName, subpath, cwd) {
3202
- let current = cwd;
3203
- while (true) {
3204
- const candidate = subpath ? join(current, "node_modules", packageName, subpath) : join(current, "node_modules", packageName);
3205
- if (existsSync(candidate)) return candidate;
3206
- const parent = dirname(current);
3207
- if (parent === current) break;
3208
- current = parent;
3627
+ function extractChangedFiles(diff) {
3628
+ const files = /* @__PURE__ */ new Set();
3629
+ for (const line of diff.split("\n")) {
3630
+ const match = line.match(/^\+\+\+ b\/(.+)$/);
3631
+ if (match && match[1] !== "/dev/null") files.add(match[1]);
3209
3632
  }
3210
- return null;
3211
- }
3212
- /** Base directory for cached git-skill clones (honours `XDG_CACHE_HOME`). */
3213
- function resolveSkillCacheDir() {
3214
- return join(process.env.XDG_CACHE_HOME?.trim() || join(homedir(), ".cache"), "code-review", "skills");
3633
+ return files;
3215
3634
  }
3216
3635
  /**
3217
- * Stable cache-directory name for a git skill. Keyed on the clone URL plus the
3218
- * pinned ref so that two refs of the same repo never share a cache entry.
3636
+ * Returns the line number for a discussion note's position.
3637
+ * Prefers the new-side line (`new_line`) then falls back to `old_line`.
3219
3638
  */
3220
- function gitSkillCacheKey(url, ref) {
3221
- return createHash("sha256").update(`${url}#${ref}`).digest("hex").slice(0, 16);
3639
+ function positionLine$1(note) {
3640
+ return note.position?.new_line ?? note.position?.old_line ?? null;
3222
3641
  }
3223
3642
  /**
3224
- * Shallow-clone `url` at `ref` into `dir`. Using init + a single-ref fetch +
3225
- * `checkout FETCH_HEAD` (rather than `clone --branch`) means a branch, tag, or
3226
- * commit SHA all resolve through the same path; an empty `ref` fetches the
3227
- * remote's default branch via `HEAD`.
3643
+ * Returns the file path for a discussion note's position.
3644
+ * Prefers the new path then falls back to the old path.
3228
3645
  */
3229
- async function gitShallowClone(url, ref, dir) {
3230
- await git$1([
3231
- "init",
3232
- "--quiet",
3233
- dir
3234
- ]);
3235
- await git$1([
3236
- "remote",
3237
- "add",
3238
- "origin",
3239
- url
3240
- ], { cwd: dir });
3241
- await git$1([
3242
- "fetch",
3243
- "--depth",
3244
- "1",
3245
- "--quiet",
3246
- "origin",
3247
- ref || "HEAD"
3248
- ], { cwd: dir });
3249
- await git$1([
3250
- "checkout",
3251
- "--quiet",
3252
- "FETCH_HEAD"
3253
- ], { cwd: dir });
3646
+ function positionFile$1(note) {
3647
+ return note.position?.new_path ?? note.position?.old_path ?? null;
3254
3648
  }
3255
3649
  /**
3256
- * Resolve a git skill spec to a local clone directory, reusing the on-disk
3257
- * cache when possible. The clone lands in a temp sibling first and is renamed
3258
- * into place atomically, so a crashed or concurrent clone never leaves a
3259
- * half-written cache entry. With `refresh`, any cached copy is discarded first.
3650
+ * Extracts prior review threads from existing MR discussions that are relevant
3651
+ * to the current diff.
3652
+ *
3653
+ * A thread is included when:
3654
+ * - It contains at least one bot note (identified by fingerprint marker).
3655
+ * - It contains at least one non-system human reply after the bot note.
3656
+ * - The thread's file appears in `changedFiles`.
3657
+ *
3658
+ * Resolved threads are included but marked with `resolved: true` so the
3659
+ * reviewer can reference them without re-raising the concern.
3260
3660
  */
3261
- async function cloneGitSkill(url, ref, options) {
3262
- const repoDir = join(options.cacheDir, gitSkillCacheKey(url, ref));
3263
- if (options.refresh) await rm(repoDir, {
3264
- recursive: true,
3265
- force: true
3266
- });
3267
- else if (existsSync(join(repoDir, ".git"))) return repoDir;
3268
- else if (existsSync(repoDir)) await rm(repoDir, {
3269
- recursive: true,
3270
- force: true
3271
- });
3272
- await mkdir(options.cacheDir, { recursive: true });
3273
- const tmpDir = `${repoDir}.tmp-${process.pid}`;
3274
- await rm(tmpDir, {
3275
- recursive: true,
3276
- force: true
3277
- });
3278
- try {
3279
- await gitShallowClone(url, ref, tmpDir);
3280
- try {
3281
- await rename(tmpDir, repoDir);
3282
- } catch (error) {
3283
- if (existsSync(join(repoDir, ".git"))) {
3284
- await rm(tmpDir, {
3285
- recursive: true,
3286
- force: true
3287
- });
3288
- return repoDir;
3289
- }
3290
- throw error;
3291
- }
3292
- } catch (error) {
3293
- await rm(tmpDir, {
3294
- recursive: true,
3295
- force: true
3661
+ function extractPriorThreads(discussions, changedFiles) {
3662
+ const threads = [];
3663
+ for (const discussion of discussions) {
3664
+ const notes = discussion.notes ?? [];
3665
+ const botNoteIndex = notes.findIndex(isBotNote);
3666
+ if (botNoteIndex === -1) continue;
3667
+ const botNote = notes[botNoteIndex];
3668
+ const file = positionFile$1(botNote);
3669
+ if (!file || !changedFiles.has(file)) continue;
3670
+ const replies = notes.slice(botNoteIndex + 1).filter((n) => !n.system && (n.body?.trim() ?? "")).filter((n) => !isBotNote(n)).map((n) => n.body?.trim() ?? "");
3671
+ if (replies.length === 0) continue;
3672
+ const resolved = notes.some((n) => n.resolved === true);
3673
+ threads.push({
3674
+ file,
3675
+ line: positionLine$1(botNote),
3676
+ resolved,
3677
+ botComment: normalizeBody(botNote.body ?? ""),
3678
+ replies
3296
3679
  });
3297
- throw error;
3298
3680
  }
3299
- return repoDir;
3681
+ return threads;
3300
3682
  }
3301
3683
  /**
3302
- * Load a skill by its spec string (`code-review`, `npm:@scope/pkg`, `file:./path`,
3303
- * `git:https://…`, …).
3304
- *
3305
- * Resolution order:
3306
- * 1. `builtin` — package-bundled `skills/<name>/`
3307
- * 2. `npm:` — `node_modules/<packageName>[/subpath]` walked up from `cwd`
3308
- * 3. `file:` — direct filesystem path (relative paths resolved from `cwd`)
3309
- * 4. `git:` / `git+ssh:` — shallow clone at the pinned ref, cached on disk,
3310
- * loading `SKILL.md` from the repo root or the `#<ref>/<subpath>` directory
3311
- *
3312
- * Throws a `ConfigError` if the spec cannot be resolved or the resolved
3313
- * directory does not contain a valid `SKILL.md`.
3684
+ * Renders a `<prior_review_feedback>` XML block from a list of prior threads.
3685
+ * Returns an empty string when `threads` is empty.
3314
3686
  */
3315
- async function loadNamedSkill(spec, cwd, options = {}) {
3316
- const parsed = parseSkillSpec(spec);
3317
- if (parsed.protocol === "builtin") {
3318
- const skill = await loadBuiltinSkill(parsed.name);
3319
- if (!skill) throw new ConfigError(`Cannot load skill: "${spec}"`, { hint: `No built-in skill named "${parsed.name}" was found. Check the skill name, or use npm: / file: to reference external skills.` });
3320
- return skill;
3321
- }
3322
- if (parsed.protocol === "npm") {
3323
- const dir = await resolveNpmSkillDir(parsed.packageName, parsed.subpath, cwd);
3324
- if (dir === null) {
3325
- const pkgRef = parsed.subpath ? `${parsed.packageName} (subpath "${parsed.subpath}")` : parsed.packageName;
3326
- throw new ConfigError(`Cannot load skill: "${spec}"`, { hint: `Package ${pkgRef} was not found in node_modules. Run \`npm install ${parsed.packageName}\` in the project.` });
3327
- }
3328
- const skill = await loadSkillFromDir(dir, "npm");
3329
- if (!skill) throw new ConfigError(`Cannot load skill: "${spec}"`, { hint: `The package at ${dir} does not contain a valid SKILL.md.` });
3330
- return skill;
3331
- }
3332
- if (parsed.protocol === "file") {
3333
- const resolvedPath = parsed.path.startsWith("/") ? parsed.path : join(cwd, parsed.path);
3334
- const skill = await loadSkillFromDir(resolvedPath, "file");
3335
- 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.` });
3336
- return skill;
3337
- }
3338
- let repoDir;
3339
- try {
3340
- repoDir = await cloneGitSkill(parsed.url, parsed.ref, {
3341
- cacheDir: options.cacheDir ?? resolveSkillCacheDir(),
3342
- refresh: options.refresh ?? false
3343
- });
3344
- } catch (error) {
3345
- const atRef = parsed.ref ? ` at ref "${parsed.ref}"` : "";
3346
- throw new ConfigError(`Cannot load skill: "${spec}"`, {
3347
- cause: error,
3348
- hint: `Failed to clone "${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`
3349
- });
3350
- }
3351
- const skill = await loadSkillFromDir(parsed.subpath ? join(repoDir, parsed.subpath) : repoDir, "git");
3352
- 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>\"." });
3353
- return skill;
3687
+ function renderPriorThreadsBlock(threads) {
3688
+ if (threads.length === 0) return "";
3689
+ return `<prior_review_feedback>\n${threads.map((t) => {
3690
+ return ` <thread ${[
3691
+ `file="${t.file}"`,
3692
+ t.line !== null ? `line="${t.line}"` : null,
3693
+ `resolved="${t.resolved}"`
3694
+ ].filter(Boolean).join(" ")}>\n${` <comment>${escapeXml(t.botComment)}</comment>`}\n${t.replies.map((r) => ` <reply>${escapeXml(r)}</reply>`).join("\n")}\n </thread>`;
3695
+ }).join("\n")}\n</prior_review_feedback>`;
3696
+ }
3697
+ function escapeXml(text) {
3698
+ return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&apos;");
3354
3699
  }
3355
3700
  //#endregion
3356
3701
  //#region src/skipped-retrieval.ts
@@ -3617,6 +3962,26 @@ var SEVERITY_RULE = {
3617
3962
  CRITICAL: "- Only report CRITICAL issues — skip WARN and INFO"
3618
3963
  };
3619
3964
  var exec = promisify(execFile);
3965
+ /**
3966
+ * Stream function for the reviewer agent.
3967
+ *
3968
+ * pi-ai >=0.82 requires an explicit stream function (earlier versions built one
3969
+ * 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.
3976
+ *
3977
+ * `stream` is injectable so the env threading can be unit-tested without a live call.
3978
+ */
3979
+ function createReviewStreamFn(stream = streamSimple) {
3980
+ return (model, context, options) => stream(model, context, {
3981
+ ...options,
3982
+ env: process.env
3983
+ });
3984
+ }
3620
3985
  function defaultCreateAgent(params) {
3621
3986
  return new Agent({
3622
3987
  initialState: {
@@ -3625,7 +3990,8 @@ function defaultCreateAgent(params) {
3625
3990
  tools: params.tools,
3626
3991
  thinkingLevel: params.thinkingLevel
3627
3992
  },
3628
- getApiKey: params.getApiKey
3993
+ getApiKey: params.getApiKey,
3994
+ streamFn: createReviewStreamFn()
3629
3995
  });
3630
3996
  }
3631
3997
  async function findGitRoot(cwd) {
@@ -3689,10 +4055,14 @@ async function loadReviewContext(cwd, skillNames = [], warn, options = {}) {
3689
4055
  walkUpContextFiles(cwd, REVIEW_RULE_FILES, gitRoot),
3690
4056
  loadAutoDiscoveredSkills(cwd, gitRoot, warn)
3691
4057
  ]);
4058
+ const registry = buildMarketplaceRegistry(options.marketplaces ?? []);
4059
+ const knownMarketplaces = new Set(registry.keys());
3692
4060
  const skills = [...discovered];
3693
4061
  const discoveredNames = new Set(discovered.map((s) => s.name));
3694
4062
  const named = await Promise.all(skillNames.filter((n) => !discoveredNames.has(n)).map(async (n) => {
3695
4063
  try {
4064
+ const spec = parseSkillSpec(n, knownMarketplaces);
4065
+ if (spec.protocol === "marketplace") return await loadMarketplaceSkill(spec, registry, { refresh: options.refreshGitSkills });
3696
4066
  return await loadNamedSkill(n, cwd, { refresh: options.refreshGitSkills });
3697
4067
  } catch (error) {
3698
4068
  warn?.(`Skipping skill "${n}": ${formatError(error)}`);
@@ -4099,7 +4469,7 @@ function resolveModel(modelString, baseUrl, maxTokens) {
4099
4469
  const { provider, modelId } = splitModel(modelString);
4100
4470
  if (provider === void 0 || modelId === void 0) throw new ReviewerError(`Invalid model format "${modelString}". Expected "provider/modelId" (e.g. "anthropic/claude-sonnet-4-5").`);
4101
4471
  if (provider === "ollama") return buildOllamaModel(modelId, baseUrl || "http://localhost:11434/v1", maxTokens);
4102
- const model = getModel(provider, modelId);
4472
+ const model = getBuiltinModel(provider, modelId);
4103
4473
  if (!model) throw new ReviewerError(`Unknown model "${modelString}".`, { hint: `Check that "${provider}" is a valid provider and "${modelId}" is a registered model ID.` });
4104
4474
  if (baseUrl || maxTokens > 0) return {
4105
4475
  ...model,
@@ -4329,7 +4699,10 @@ async function runReview(config, options) {
4329
4699
  retrieved: retrievableSkipped.length > 0
4330
4700
  };
4331
4701
  }
4332
- const context = await loadReviewContext(cwd, config.skills, (msg) => logger.warn(msg), { refreshGitSkills: config.refreshGitSkills });
4702
+ const context = await loadReviewContext(cwd, config.skills, (msg) => logger.warn(msg), {
4703
+ refreshGitSkills: config.refreshGitSkills,
4704
+ marketplaces: config.marketplaces
4705
+ });
4333
4706
  const systemPrompt = buildJSONSystemPrompt(context, minSeverity);
4334
4707
  const userPrompt = buildUserPrompt(promptDiff, promptSkippedFiles, options.commitLog, options.priorThreads, options.intent, promptCoverage, retrievableSkipped, diskMode, commitsMode ? { sinceRef: options.sinceRef } : void 0);
4335
4708
  const skillNames = context.skills.map((s) => s.name);
@@ -5265,7 +5638,7 @@ async function loadDefaultRuntime() {
5265
5638
  const [sdkNode, resources, semconv] = modules;
5266
5639
  const serviceResource = resources.resourceFromAttributes({
5267
5640
  [semconv.ATTR_SERVICE_NAME ?? "service.name"]: SERVICE_NAME,
5268
- [semconv.ATTR_SERVICE_VERSION ?? "service.version"]: "0.9.3"
5641
+ [semconv.ATTR_SERVICE_VERSION ?? "service.version"]: "0.9.5"
5269
5642
  });
5270
5643
  applyOtelExporterDefaults(process.env);
5271
5644
  const sdk = new sdkNode.NodeSDK({ resource: resources.defaultResource().merge(serviceResource) });
@@ -5737,7 +6110,7 @@ function boldCommentTitle(body) {
5737
6110
  */
5738
6111
  function buildCommentBody(body, commitSha, confidence) {
5739
6112
  const confidenceLine = `_Confidence: ${confidence}._`;
5740
- const footer = `<sub>Reviewed by ${PRODUCT_LINK} v0.9.3 for commit ${commitSha}.</sub>`;
6113
+ const footer = `<sub>Reviewed by ${PRODUCT_LINK} v0.9.5 for commit ${commitSha}.</sub>`;
5741
6114
  return `${boldCommentTitle(body.trim())}\n\n${confidenceLine}\n\n---\n\n${footer}`;
5742
6115
  }
5743
6116
  function buildPayload(comment, body, refs, resolved) {
@@ -6844,10 +7217,10 @@ async function main(argv = process.argv.slice(2)) {
6844
7217
  return;
6845
7218
  }
6846
7219
  if (argv.includes("--version") || argv.includes("-v")) {
6847
- console.log("0.9.3");
7220
+ console.log("0.9.5");
6848
7221
  return;
6849
7222
  }
6850
- process.stderr.write(`[code-review] @weareikko/code-review v0.9.3\n`);
7223
+ process.stderr.write(`[code-review] @weareikko/code-review v0.9.5\n`);
6851
7224
  assertNodeVersion();
6852
7225
  applyCodeReviewEnvPrefix();
6853
7226
  applyDefaultCacheRetention();
@@ -6868,6 +7241,6 @@ if (isDirectRun()) main().catch((error) => {
6868
7241
  process.exitCode = 1;
6869
7242
  });
6870
7243
  //#endregion
6871
- 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, resolveNpmSkillDir as _, main as a, parseReviewMarkdownWithWarnings as b, buildGeneratedComments as c, startOtelBridge as d, sha256 as et, filterDiff as f, parseSkillSpec as g, loadNamedSkill as h, formatUsageLine as i, SUMMARY_HISTORY_ENTRY_END as j, toGitLabReviewSeverity as k, buildPayload as l, gitSkillCacheKey as m, formatPerModelUsage as n, run as o, runReview as p, stripSummaryMarker as q, formatSkillsFooter as r, withHttpStamping as s, countPostedBySeverity as t, isOtelEnabled as u, resolveSkillCacheDir as v, createDiagnosticRunId as w, DIAGNOSTIC_CHANNEL_NAMES as x, parseReviewMarkdown as y, buildSummaryBody as z };
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 };
6872
7245
 
6873
- //# sourceMappingURL=cli-BaZloofb.js.map
7246
+ //# sourceMappingURL=cli-tIo5jQvJ.js.map