@weareikko/code-review 0.9.2 → 0.9.4

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,20 @@
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
5
  import { getEnvApiKey, getModel } from "@earendil-works/pi-ai";
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";
12
14
  import { createReadOnlyTools } from "@earendil-works/pi-coding-agent";
13
15
  import { createTwoFilesPatch } from "diff";
14
16
  import * as git from "isomorphic-git";
15
17
  import { Type } from "typebox";
16
- import { homedir } from "node:os";
17
- import { parse } from "yaml";
18
18
  import { SpanKind, SpanStatusCode, context, metrics, trace } from "@opentelemetry/api";
19
19
  import { SeverityNumber, logs } from "@opentelemetry/api-logs";
20
20
  //#region src/errors.ts
@@ -336,46 +336,875 @@ var GitHubClient = class {
336
336
  responseBody: text,
337
337
  hint: "Ensure the token can read pull-request review threads (pull-requests: read / repo scope)."
338
338
  });
339
- return parsed.data;
339
+ return parsed.data;
340
+ }
341
+ /**
342
+ * Return the database IDs of review comments that belong to a **settled**
343
+ * review thread — one that is either resolved or outdated. GitHub's REST
344
+ * comment endpoints omit both states; they are only exposed via GraphQL
345
+ * `reviewThreads.isResolved` / `isOutdated`. Callers use this set to mark
346
+ * normalized notes resolved so settled threads are excluded from summary
347
+ * carry-over and prior-thread context. Paginates over threads.
348
+ *
349
+ * Outdated counts as settled because GitHub, unlike GitLab, does not
350
+ * auto-resolve a thread when the line it anchors to changes: fixing a finding
351
+ * flips the thread to outdated but leaves `isResolved` false until someone
352
+ * manually resolves it. Treating outdated as settled mirrors GitLab's
353
+ * "automatically resolve outdated diff discussions" behaviour, so a fixed
354
+ * finding stops being re-listed under "Still open from earlier reviews" (#133).
355
+ */
356
+ async listSettledReviewCommentIds(owner, repo, pull) {
357
+ const settled = /* @__PURE__ */ new Set();
358
+ let cursor = null;
359
+ let hasNext = true;
360
+ while (hasNext) {
361
+ const threads = (await this.graphql(REVIEW_THREADS_QUERY, {
362
+ owner,
363
+ repo,
364
+ pull,
365
+ cursor
366
+ })).repository?.pullRequest?.reviewThreads;
367
+ if (!threads) break;
368
+ for (const thread of threads.nodes ?? []) {
369
+ if (!thread.isResolved && !thread.isOutdated) continue;
370
+ for (const comment of thread.comments?.nodes ?? []) if (typeof comment.databaseId === "number") settled.add(comment.databaseId);
371
+ }
372
+ hasNext = threads.pageInfo?.hasNextPage ?? false;
373
+ cursor = threads.pageInfo?.endCursor ?? null;
374
+ if (!cursor) hasNext = false;
375
+ }
376
+ return settled;
377
+ }
378
+ };
379
+ //#endregion
380
+ //#region src/git.ts
381
+ var exec$1 = promisify(execFile);
382
+ var DEFAULT_DIFF_CONTEXT = 20;
383
+ var DEFAULT_CODEQUALITY_ARTIFACTS = [
384
+ "gl-code-quality-report.json",
385
+ "codequality.json",
386
+ "codeclimate.json",
387
+ "code-quality-report.json"
388
+ ];
389
+ function gitErrorMessage(error) {
390
+ const err = error;
391
+ return [
392
+ err.message,
393
+ err.stderr,
394
+ err.stdout
395
+ ].filter(Boolean).join("\n").trim();
396
+ }
397
+ async function git$1(args, options = {}) {
398
+ try {
399
+ const { stdout } = await exec$1("git", args, {
400
+ cwd: options.cwd,
401
+ maxBuffer: 50 * 1024 * 1024
402
+ });
403
+ return stdout;
404
+ } catch (error) {
405
+ throw new GitError(`git ${args.join(" ")} failed.`, {
406
+ cause: error,
407
+ hint: gitErrorMessage(error)
408
+ });
409
+ }
410
+ }
411
+ function remoteRef(remote, branch) {
412
+ return `refs/remotes/${remote}/${branch}`;
413
+ }
414
+ function getMergeDiffArguments(targetBranch, options = {}) {
415
+ const remote = options.remote ?? "origin";
416
+ const context = options.context ?? DEFAULT_DIFF_CONTEXT;
417
+ return [
418
+ `${remoteRef(remote, targetBranch)}...HEAD`,
419
+ `--unified=${context}`,
420
+ "--"
421
+ ];
422
+ }
423
+ /**
424
+ * Full git argv for the merge diff. `-c core.quotepath=false` keeps non-ASCII
425
+ * paths literal instead of git's default octal-escaped + double-quoted form
426
+ * (`"a/caf\303\251.ts"`), which the comment-position parser cannot match —
427
+ * leaving such comments with invalid one-sided positions that 500 on
428
+ * `bulk_publish`. Diffing literally also gives the reviewer readable paths.
429
+ */
430
+ function getMergeDiffCommand(targetBranch, options = {}) {
431
+ return [
432
+ "-c",
433
+ "core.quotepath=false",
434
+ "diff",
435
+ ...getMergeDiffArguments(targetBranch, options)
436
+ ];
437
+ }
438
+ async function fetchBranch(remote, branch, options) {
439
+ await git$1([
440
+ "fetch",
441
+ "--no-tags",
442
+ remote,
443
+ `+refs/heads/${branch}:${remoteRef(remote, branch)}`
444
+ ], options);
445
+ }
446
+ async function isTracked(path, options) {
447
+ try {
448
+ await git$1([
449
+ "ls-files",
450
+ "--error-unmatch",
451
+ "--",
452
+ path
453
+ ], options);
454
+ return true;
455
+ } catch {
456
+ return false;
457
+ }
458
+ }
459
+ async function removeGeneratedCodeQualityArtifacts(paths = DEFAULT_CODEQUALITY_ARTIFACTS, options = {}) {
460
+ const removed = [];
461
+ for (const path of paths) {
462
+ if (await isTracked(path, options)) continue;
463
+ try {
464
+ await unlink(options.cwd ? join(options.cwd, path) : path);
465
+ removed.push(path);
466
+ } catch (error) {
467
+ if (error.code !== "ENOENT") throw error;
468
+ }
469
+ }
470
+ return removed;
471
+ }
472
+ async function prepareGitHistory(sourceBranch, targetBranch, options = {}) {
473
+ const remote = options.remote ?? "origin";
474
+ await removeGeneratedCodeQualityArtifacts(options.codeQualityArtifacts, options);
475
+ await git$1([
476
+ "fetch",
477
+ "--unshallow",
478
+ "--no-tags",
479
+ remote
480
+ ], options).catch(() => void 0);
481
+ const fetchErrors = [];
482
+ for (const branch of [targetBranch, sourceBranch]) try {
483
+ await fetchBranch(remote, branch, options);
484
+ } catch (error) {
485
+ fetchErrors.push(`${branch}: ${gitErrorMessage(error)}`);
486
+ }
487
+ if (fetchErrors.length === 2) throw new GitError(`Unable to fetch MR source/target branches from ${remote}.`, { hint: fetchErrors.join("\n") });
488
+ try {
489
+ await git$1([
490
+ "merge-base",
491
+ remoteRef(remote, targetBranch),
492
+ "HEAD"
493
+ ], options);
494
+ } catch (error) {
495
+ const fetchDetail = fetchErrors.length > 0 ? `\nFetch warnings:\n${fetchErrors.join("\n")}` : "";
496
+ throw new GitError(`Unable to prepare Git history for MR review: merge-base ${remoteRef(remote, targetBranch)} HEAD failed.`, {
497
+ cause: error,
498
+ hint: `Set GIT_DEPTH: 0 or ensure ${remote}/${targetBranch} is fetchable.${fetchDetail}\n${gitErrorMessage(error)}`
499
+ });
500
+ }
501
+ }
502
+ async function getMergeDiff(targetBranch, options = {}) {
503
+ return git$1(getMergeDiffCommand(targetBranch, options), options);
504
+ }
505
+ function getMergeCommitLogArguments(targetBranch, options = {}) {
506
+ return [
507
+ `${remoteRef(options.remote ?? "origin", targetBranch)}...HEAD`,
508
+ "--pretty=tformat:commit %h%nAuthor: %an%nDate: %as%n%n%s%n%n%b",
509
+ "--reverse",
510
+ "--no-merges"
511
+ ];
512
+ }
513
+ async function getMergeCommitLog(targetBranch, options = {}) {
514
+ return git$1(["log", ...getMergeCommitLogArguments(targetBranch, options)], options);
515
+ }
516
+ /**
517
+ * Summarize a unified diff into file/line counts for telemetry. Counts one file
518
+ * per `diff --git` header and counts `+`/`-` lines only inside a hunk (after a
519
+ * `@@` header), so the `--- a/file` / `+++ b/file` header lines are excluded and
520
+ * a genuine content line whose text starts with `++`/`--` is still counted. Pure
521
+ * and allocation-light so it can run on the full merge diff without an extra git
522
+ * invocation.
523
+ */
524
+ function summarizeDiff(diff) {
525
+ let filesChanged = 0;
526
+ let linesAdded = 0;
527
+ let linesRemoved = 0;
528
+ let inHunk = false;
529
+ for (const line of diff.split("\n")) if (line.startsWith("diff --git ")) {
530
+ filesChanged += 1;
531
+ inHunk = false;
532
+ } else if (line.startsWith("@@ ")) inHunk = true;
533
+ else if (inHunk && line.startsWith("+")) linesAdded += 1;
534
+ else if (inHunk && line.startsWith("-")) linesRemoved += 1;
535
+ return {
536
+ filesChanged,
537
+ linesAdded,
538
+ linesRemoved
539
+ };
540
+ }
541
+ //#endregion
542
+ //#region src/skills.ts
543
+ var SKILL_DIRS = [".agents/skills", ".claude/skills"];
544
+ var RESOURCE_DIRS = ["references"];
545
+ function parseFrontmatter(content) {
546
+ const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
547
+ if (!match) return null;
548
+ let data;
549
+ try {
550
+ data = parse(match[1]);
551
+ } catch {
552
+ return null;
553
+ }
554
+ if (!data || typeof data !== "object") return null;
555
+ const { name, description } = data;
556
+ if (typeof name !== "string" || typeof description !== "string") return null;
557
+ const trimmedName = name.trim();
558
+ const trimmedDescription = description.trim();
559
+ if (!trimmedName || !trimmedDescription) return null;
560
+ return {
561
+ name: trimmedName,
562
+ description: trimmedDescription
563
+ };
564
+ }
565
+ async function loadSkillFromDir(dirPath, source) {
566
+ const skillMdPath = join(dirPath, "SKILL.md");
567
+ let content;
568
+ try {
569
+ content = await readFile(skillMdPath, "utf8");
570
+ } catch {
571
+ return null;
572
+ }
573
+ const parsed = parseFrontmatter(content);
574
+ if (!parsed) return null;
575
+ const resourceDirs = RESOURCE_DIRS.filter((d) => existsSync(join(dirPath, d)));
576
+ return {
577
+ name: parsed.name,
578
+ description: parsed.description,
579
+ filePath: skillMdPath,
580
+ rootDir: dirPath,
581
+ resourceDirs,
582
+ source
583
+ };
584
+ }
585
+ function resolveBuiltinSkillsDir() {
586
+ return join(dirname(fileURLToPath(import.meta.url)), "..", "skills");
587
+ }
588
+ async function loadBuiltinSkill(name) {
589
+ return loadSkillFromDir(join(resolveBuiltinSkillsDir(), name), "builtin");
590
+ }
591
+ async function loadAutoDiscoveredSkills(cwd, gitRoot, warn) {
592
+ const dirs = [];
593
+ let current = cwd;
594
+ while (true) {
595
+ dirs.unshift(current);
596
+ if (current === gitRoot) break;
597
+ const parent = dirname(current);
598
+ if (parent === current) break;
599
+ current = parent;
600
+ }
601
+ const found = /* @__PURE__ */ new Map();
602
+ for (const dir of dirs) for (const skillDir of SKILL_DIRS) {
603
+ const skillsPath = join(dir, skillDir);
604
+ let entries;
605
+ try {
606
+ entries = await readdir(skillsPath);
607
+ } catch {
608
+ continue;
609
+ }
610
+ for (const entry of entries) {
611
+ const entryPath = join(skillsPath, entry);
612
+ const skill = await loadSkillFromDir(entryPath, "project");
613
+ if (skill) found.set(skill.name, skill);
614
+ 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.`);
615
+ }
616
+ }
617
+ return [...found.values()];
618
+ }
619
+ /**
620
+ * Parse a skill spec string into a typed `SkillSpec` descriptor.
621
+ *
622
+ * Supported spec formats:
623
+ *
624
+ * | Input | Result |
625
+ * |------------------------------------|---------------------------------------------------|
626
+ * | `code-review` | `{ protocol: 'builtin', name: 'code-review' }` |
627
+ * | `npm:my-skill` | `{ protocol: 'npm', packageName: 'my-skill', ... }`|
628
+ * | `npm:@scope/pkg` | `{ protocol: 'npm', packageName: '@scope/pkg', ... }`|
629
+ * | `npm:@scope/bundle/security` | `{ protocol: 'npm', packageName: '@scope/bundle', subpath: 'security' }`|
630
+ * | `npm:bundle/security` | `{ protocol: 'npm', packageName: 'bundle', subpath: 'security' }`|
631
+ * | `file:./path/to/skill` | `{ protocol: 'file', path: './path/to/skill' }` |
632
+ * | `file:/absolute/path` | `{ protocol: 'file', path: '/absolute/path' }` |
633
+ * | `git:https://host/org/s.git` | `{ protocol: 'git', url: 'https://host/org/s.git', ref: '', subpath: '' }` |
634
+ * | `git:https://host/org/b.git#v1/sec`| `{ protocol: 'git', url: 'https://host/org/b.git', ref: 'v1', subpath: 'sec' }` |
635
+ * | `git+ssh://git@host/org/s.git` | `{ protocol: 'git', url: 'ssh://git@host/org/s.git', ref: '', subpath: '' }` |
636
+ * | `acme:dev/aria-apg` (acme known) | `{ protocol: 'marketplace', marketplace: 'acme', plugin: 'dev', skill: 'aria-apg' }` |
637
+ *
638
+ * A spec whose portion before the first `:` matches a registered marketplace
639
+ * name (from `knownMarketplaces`) is parsed as a `marketplace` reference. The
640
+ * remainder must be `<plugin>/<skill>` — an explicit skill is always required.
641
+ */
642
+ function parseSkillSpec(spec, knownMarketplaces = /* @__PURE__ */ new Set()) {
643
+ if (spec.startsWith("file:")) return {
644
+ protocol: "file",
645
+ path: spec.slice(5)
646
+ };
647
+ if (spec.startsWith("npm:")) {
648
+ const rest = spec.slice(4);
649
+ if (rest.startsWith("@")) {
650
+ const parts = rest.split("/");
651
+ if (parts.length < 2) return {
652
+ protocol: "npm",
653
+ packageName: rest,
654
+ subpath: ""
655
+ };
656
+ return {
657
+ protocol: "npm",
658
+ packageName: `${parts[0]}/${parts[1]}`,
659
+ subpath: parts.slice(2).join("/")
660
+ };
661
+ }
662
+ const slashIdx = rest.indexOf("/");
663
+ if (slashIdx === -1) return {
664
+ protocol: "npm",
665
+ packageName: rest,
666
+ subpath: ""
667
+ };
668
+ return {
669
+ protocol: "npm",
670
+ packageName: rest.slice(0, slashIdx),
671
+ subpath: rest.slice(slashIdx + 1)
672
+ };
673
+ }
674
+ if (spec.startsWith("git+") || spec.startsWith("git:")) return parseGitSpec(spec);
675
+ const colonIdx = spec.indexOf(":");
676
+ if (colonIdx > 0 && knownMarketplaces.has(spec.slice(0, colonIdx))) return parseMarketplaceSkillSpec(spec, colonIdx);
677
+ return {
678
+ protocol: "builtin",
679
+ name: spec
680
+ };
681
+ }
682
+ /**
683
+ * Parse the `<marketplace>:<plugin>/<skill>` selector into its parts. The
684
+ * marketplace name has already been matched against the registry by the caller;
685
+ * `colonIdx` is the index of the separating `:`. Throws a `ConfigError` when the
686
+ * `<plugin>/<skill>` remainder is malformed (a skill name is always required).
687
+ */
688
+ function parseMarketplaceSkillSpec(spec, colonIdx) {
689
+ const marketplace = spec.slice(0, colonIdx);
690
+ const rest = spec.slice(colonIdx + 1);
691
+ const slashIdx = rest.indexOf("/");
692
+ const plugin = slashIdx === -1 ? rest : rest.slice(0, slashIdx);
693
+ const skill = slashIdx === -1 ? "" : rest.slice(slashIdx + 1);
694
+ 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".` });
695
+ return {
696
+ protocol: "marketplace",
697
+ marketplace,
698
+ plugin,
699
+ skill
700
+ };
701
+ }
702
+ /**
703
+ * Parse a `git:` / `git+ssh:` skill spec into its URL, pinned ref, and subpath.
704
+ *
705
+ * - `git:<url>` strips the `git:` marker; what follows is the clone URL
706
+ * (e.g. `git:https://host/org/repo.git`).
707
+ * - `git+<transport>://…` strips the leading `git+`, leaving a URL git
708
+ * understands directly (`git+ssh://git@host/…` → `ssh://git@host/…`), matching
709
+ * npm's `package.json` git-dependency convention.
710
+ *
711
+ * An optional `#<ref>[/<subpath>]` fragment pins the ref (tag, branch, or
712
+ * commit) and, after the first `/`, points at a skill directory inside the repo.
713
+ */
714
+ function parseGitSpec(spec) {
715
+ const raw = spec.startsWith("git+") ? spec.slice(4) : spec.slice(4);
716
+ let parsed;
717
+ try {
718
+ parsed = new URL(raw);
719
+ } catch {
720
+ return {
721
+ protocol: "git",
722
+ url: raw,
723
+ ref: "",
724
+ subpath: ""
725
+ };
726
+ }
727
+ const fragment = parsed.hash ? parsed.hash.slice(1) : "";
728
+ parsed.hash = "";
729
+ const url = parsed.toString();
730
+ const slashIdx = fragment.indexOf("/");
731
+ if (slashIdx === -1) return {
732
+ protocol: "git",
733
+ url,
734
+ ref: fragment,
735
+ subpath: ""
736
+ };
737
+ return {
738
+ protocol: "git",
739
+ url,
740
+ ref: fragment.slice(0, slashIdx),
741
+ subpath: fragment.slice(slashIdx + 1)
742
+ };
743
+ }
744
+ /**
745
+ * Resolve the directory for an npm-installed skill by walking `node_modules`
746
+ * upward from `cwd` (supports monorepo hoisting). Returns the resolved path
747
+ * or `null` if the package / subpath cannot be found.
748
+ */
749
+ async function resolveNpmSkillDir(packageName, subpath, cwd) {
750
+ let current = cwd;
751
+ while (true) {
752
+ const candidate = subpath ? join(current, "node_modules", packageName, subpath) : join(current, "node_modules", packageName);
753
+ if (existsSync(candidate)) return candidate;
754
+ const parent = dirname(current);
755
+ if (parent === current) break;
756
+ current = parent;
757
+ }
758
+ return null;
759
+ }
760
+ /** Strip a leading `git+` transport marker (`git+ssh://…` → `ssh://…`). */
761
+ function normalizeGitUrl(raw) {
762
+ return raw.startsWith("git+") ? raw.slice(4) : raw;
763
+ }
764
+ /**
765
+ * Remove embedded credentials from a URL so it is safe to show in logs, hints,
766
+ * and errors. `https://user:token@host/path` becomes `https://***@host/path`.
767
+ * Non-URL strings are returned with a best-effort `//user@` → `//***@` scrub.
768
+ */
769
+ function redactUrl(url) {
770
+ try {
771
+ const parsed = new URL(url);
772
+ if (parsed.username || parsed.password) {
773
+ parsed.username = "***";
774
+ parsed.password = "";
775
+ }
776
+ return parsed.toString();
777
+ } catch {
778
+ return url.replace(/\/\/[^/@]+@/, "//***@");
779
+ }
780
+ }
781
+ /** Base directory for cached git-skill clones (honours `XDG_CACHE_HOME`). */
782
+ function resolveSkillCacheDir() {
783
+ return join(process.env.XDG_CACHE_HOME?.trim() || join(homedir(), ".cache"), "code-review", "skills");
784
+ }
785
+ /**
786
+ * Stable cache-directory name for a git skill. Keyed on the clone URL plus the
787
+ * pinned ref so that two refs of the same repo never share a cache entry.
788
+ */
789
+ function gitSkillCacheKey(url, ref) {
790
+ return createHash("sha256").update(`${url}#${ref}`).digest("hex").slice(0, 16);
791
+ }
792
+ /**
793
+ * Shallow-clone `url` at `ref` into `dir`. Using init + a single-ref fetch +
794
+ * `checkout FETCH_HEAD` (rather than `clone --branch`) means a branch, tag, or
795
+ * commit SHA all resolve through the same path; an empty `ref` fetches the
796
+ * remote's default branch via `HEAD`.
797
+ */
798
+ async function gitShallowClone(url, ref, dir) {
799
+ await git$1([
800
+ "init",
801
+ "--quiet",
802
+ dir
803
+ ]);
804
+ await git$1([
805
+ "remote",
806
+ "add",
807
+ "origin",
808
+ url
809
+ ], { cwd: dir });
810
+ await git$1([
811
+ "fetch",
812
+ "--depth",
813
+ "1",
814
+ "--quiet",
815
+ "origin",
816
+ ref || "HEAD"
817
+ ], { cwd: dir });
818
+ await git$1([
819
+ "checkout",
820
+ "--quiet",
821
+ "FETCH_HEAD"
822
+ ], { cwd: dir });
823
+ }
824
+ /** Monotonic counter making each clone's temp dir unique within the process. */
825
+ var cloneSeq = 0;
826
+ /**
827
+ * Resolve a git URL + ref to a local clone directory, reusing the on-disk cache
828
+ * when possible. The clone lands in a temp sibling first and is renamed into
829
+ * place atomically, so a crashed or concurrent clone never leaves a half-written
830
+ * cache entry. With `refresh`, any cached copy is discarded first. Shared by
831
+ * `git:` skills and Claude-plugin marketplaces (see `marketplaces.ts`).
832
+ */
833
+ async function cloneGitRepo(url, ref, options) {
834
+ const repoDir = join(options.cacheDir, gitSkillCacheKey(url, ref));
835
+ if (options.refresh) await rm(repoDir, {
836
+ recursive: true,
837
+ force: true
838
+ });
839
+ else if (existsSync(join(repoDir, ".git"))) return repoDir;
840
+ else if (existsSync(repoDir)) await rm(repoDir, {
841
+ recursive: true,
842
+ force: true
843
+ });
844
+ await mkdir(options.cacheDir, { recursive: true });
845
+ const tmpDir = `${repoDir}.tmp-${process.pid}-${cloneSeq += 1}`;
846
+ await rm(tmpDir, {
847
+ recursive: true,
848
+ force: true
849
+ });
850
+ try {
851
+ await gitShallowClone(url, ref, tmpDir);
852
+ try {
853
+ await rename(tmpDir, repoDir);
854
+ } catch (error) {
855
+ if (existsSync(join(repoDir, ".git"))) {
856
+ await rm(tmpDir, {
857
+ recursive: true,
858
+ force: true
859
+ });
860
+ return repoDir;
861
+ }
862
+ throw error;
863
+ }
864
+ } catch (error) {
865
+ await rm(tmpDir, {
866
+ recursive: true,
867
+ force: true
868
+ });
869
+ throw error;
870
+ }
871
+ return repoDir;
872
+ }
873
+ /**
874
+ * Load a skill by its spec string (`code-review`, `npm:@scope/pkg`, `file:./path`,
875
+ * `git:https://…`, …).
876
+ *
877
+ * Resolution order:
878
+ * 1. `builtin` — package-bundled `skills/<name>/`
879
+ * 2. `npm:` — `node_modules/<packageName>[/subpath]` walked up from `cwd`
880
+ * 3. `file:` — direct filesystem path (relative paths resolved from `cwd`)
881
+ * 4. `git:` / `git+ssh:` — shallow clone at the pinned ref, cached on disk,
882
+ * loading `SKILL.md` from the repo root or the `#<ref>/<subpath>` directory
883
+ *
884
+ * Throws a `ConfigError` if the spec cannot be resolved or the resolved
885
+ * directory does not contain a valid `SKILL.md`.
886
+ */
887
+ async function loadNamedSkill(spec, cwd, options = {}) {
888
+ const parsed = parseSkillSpec(spec);
889
+ if (parsed.protocol === "builtin") {
890
+ const skill = await loadBuiltinSkill(parsed.name);
891
+ 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.` });
892
+ return skill;
893
+ }
894
+ if (parsed.protocol === "npm") {
895
+ const dir = await resolveNpmSkillDir(parsed.packageName, parsed.subpath, cwd);
896
+ if (dir === null) {
897
+ const pkgRef = parsed.subpath ? `${parsed.packageName} (subpath "${parsed.subpath}")` : parsed.packageName;
898
+ throw new ConfigError(`Cannot load skill: "${spec}"`, { hint: `Package ${pkgRef} was not found in node_modules. Run \`npm install ${parsed.packageName}\` in the project.` });
899
+ }
900
+ const skill = await loadSkillFromDir(dir, "npm");
901
+ if (!skill) throw new ConfigError(`Cannot load skill: "${spec}"`, { hint: `The package at ${dir} does not contain a valid SKILL.md.` });
902
+ return skill;
903
+ }
904
+ if (parsed.protocol === "file") {
905
+ const resolvedPath = parsed.path.startsWith("/") ? parsed.path : join(cwd, parsed.path);
906
+ const skill = await loadSkillFromDir(resolvedPath, "file");
907
+ 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.` });
908
+ return skill;
909
+ }
910
+ if (parsed.protocol === "marketplace") throw new ConfigError(`Cannot load skill: "${spec}"`, { hint: "Marketplace skills must be resolved through a registered marketplace, not loaded directly." });
911
+ let repoDir;
912
+ try {
913
+ repoDir = await cloneGitRepo(parsed.url, parsed.ref, {
914
+ cacheDir: options.cacheDir ?? resolveSkillCacheDir(),
915
+ refresh: options.refresh ?? false
916
+ });
917
+ } catch (error) {
918
+ const atRef = parsed.ref ? ` at ref "${parsed.ref}"` : "";
919
+ throw new ConfigError(`Cannot load skill: "${spec}"`, {
920
+ cause: error,
921
+ 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`
922
+ });
923
+ }
924
+ const skill = await loadSkillFromDir(parsed.subpath ? join(repoDir, parsed.subpath) : repoDir, "git");
925
+ 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>\"." });
926
+ return skill;
927
+ }
928
+ //#endregion
929
+ //#region src/marketplaces.ts
930
+ /**
931
+ * Marketplace manifest formats we know how to read. Different vendors ship the
932
+ * same open SKILL.md standard but package it differently: Claude Code uses a
933
+ * `.claude-plugin/marketplace.json` catalog (`anthropic`), OpenAI Codex uses a
934
+ * `.codex-plugin/` layout (a future `codex` format). Only `anthropic` is
935
+ * implemented today; the type is the extension point for the rest.
936
+ */
937
+ var MARKETPLACE_FORMATS = ["anthropic"];
938
+ /** The default format assumed when a marketplace declaration omits a prefix. */
939
+ var DEFAULT_MARKETPLACE_FORMAT = "anthropic";
940
+ /**
941
+ * Marketplace names that collide with skill-spec protocol prefixes
942
+ * (`npm:`, `file:`, `git:`) or the bare-name builtin lookup, and so cannot be
943
+ * used as a marketplace name — `<name>:<plugin>/<skill>` would be ambiguous.
944
+ */
945
+ var RESERVED_MARKETPLACE_NAMES = new Set([
946
+ "npm",
947
+ "file",
948
+ "git",
949
+ "builtin"
950
+ ]);
951
+ var NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
952
+ var FORMAT_PREFIX_PATTERN = /^([a-z][a-z0-9-]*):(?!\/\/)/;
953
+ /**
954
+ * Parse a single marketplace declaration: `<name>=<[format:]url[#ref]>`.
955
+ *
956
+ * - `acme=https://host/group/tools.git#1.0.0` → default `anthropic` format
957
+ * - `acme=anthropic:https://host/group/tools.git#1.0.0` → explicit format
958
+ * - `acme=git+ssh://git@host/group/tools.git#main` → SSH transport
959
+ *
960
+ * The `#<ref>` fragment is taken whole as the ref (branch names may contain
961
+ * `/`). Throws a `ConfigError` with an actionable hint on any malformed input.
962
+ */
963
+ function parseMarketplaceEntry(entry) {
964
+ const eqIdx = entry.indexOf("=");
965
+ 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." });
966
+ const name = entry.slice(0, eqIdx).trim();
967
+ 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 \"-\"." });
968
+ 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.` });
969
+ let rhs = entry.slice(eqIdx + 1).trim();
970
+ let format = DEFAULT_MARKETPLACE_FORMAT;
971
+ const formatMatch = rhs.match(FORMAT_PREFIX_PATTERN);
972
+ if (formatMatch) {
973
+ const token = formatMatch[1];
974
+ 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}".` });
975
+ format = token;
976
+ rhs = rhs.slice(formatMatch[0].length);
977
+ }
978
+ let parsed;
979
+ try {
980
+ parsed = new URL(normalizeGitUrl(rhs));
981
+ } catch {
982
+ 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." });
983
+ }
984
+ const ref = parsed.hash ? parsed.hash.slice(1) : "";
985
+ parsed.hash = "";
986
+ return {
987
+ name,
988
+ format,
989
+ url: parsed.toString(),
990
+ ref
991
+ };
992
+ }
993
+ /**
994
+ * Build a name→ref registry from parsed marketplace declarations, rejecting
995
+ * duplicate names (a later entry silently shadowing an earlier one is a config
996
+ * bug worth surfacing).
997
+ */
998
+ function buildMarketplaceRegistry(refs) {
999
+ const registry = /* @__PURE__ */ new Map();
1000
+ for (const ref of refs) {
1001
+ if (registry.has(ref.name)) throw new ConfigError(`Duplicate marketplace name "${ref.name}"`, { hint: "Each marketplace must be declared once. Remove the duplicate declaration." });
1002
+ registry.set(ref.name, ref);
1003
+ }
1004
+ return registry;
1005
+ }
1006
+ /**
1007
+ * Resolve a `<marketplace>:<plugin>/<skill>` reference to a loaded {@link Skill}.
1008
+ *
1009
+ * Clones the marketplace repo (reusing the shared on-disk clone cache), then
1010
+ * dispatches to the format-specific resolver. Throws a `ConfigError` with a
1011
+ * redacted, actionable hint when the marketplace, plugin, or skill cannot be
1012
+ * resolved — the caller treats that as a skip-and-warn, not a fatal error.
1013
+ */
1014
+ async function loadMarketplaceSkill(spec, registry, options = {}) {
1015
+ const ref = `${spec.marketplace}:${spec.plugin}/${spec.skill}`;
1016
+ const mp = registry.get(spec.marketplace);
1017
+ 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.` });
1018
+ let repoDir;
1019
+ try {
1020
+ repoDir = await cloneGitRepo(mp.url, mp.ref, {
1021
+ cacheDir: options.cacheDir ?? resolveSkillCacheDir(),
1022
+ refresh: options.refresh ?? false
1023
+ });
1024
+ } catch (error) {
1025
+ const atRef = mp.ref ? ` at ref "${mp.ref}"` : "";
1026
+ throw new ConfigError(`Cannot load marketplace "${mp.name}"`, {
1027
+ cause: error,
1028
+ 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.`
1029
+ });
1030
+ }
1031
+ switch (mp.format) {
1032
+ case "anthropic": return resolveAnthropicSkill(repoDir, mp, spec);
1033
+ default: {
1034
+ const exhaustive = mp.format;
1035
+ throw new ConfigError(`Unsupported marketplace format: "${String(exhaustive)}"`, { hint: `Supported formats: ${MARKETPLACE_FORMATS.join(", ")}.` });
1036
+ }
1037
+ }
1038
+ }
1039
+ /** Resolve a skill inside a Claude Code (`anthropic`) plugin marketplace. */
1040
+ async function resolveAnthropicSkill(repoDir, mp, spec) {
1041
+ const ref = `${mp.name}:${spec.plugin}/${spec.skill}`;
1042
+ const realRoot = await realpath(repoDir);
1043
+ const manifestRaw = await readTextInside(realRoot, join(repoDir, ".claude-plugin", "marketplace.json"), ref);
1044
+ 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}")` : ""}.` });
1045
+ let manifest;
1046
+ try {
1047
+ manifest = JSON.parse(manifestRaw);
1048
+ } catch (error) {
1049
+ throw new ConfigError(`Marketplace "${mp.name}" is not a valid Anthropic plugin marketplace`, {
1050
+ cause: error,
1051
+ hint: `.claude-plugin/marketplace.json at "${redactUrl(mp.url)}" is not valid JSON.`
1052
+ });
340
1053
  }
341
- /**
342
- * Return the database IDs of review comments that belong to a **settled**
343
- * review thread — one that is either resolved or outdated. GitHub's REST
344
- * comment endpoints omit both states; they are only exposed via GraphQL
345
- * `reviewThreads.isResolved` / `isOutdated`. Callers use this set to mark
346
- * normalized notes resolved so settled threads are excluded from summary
347
- * carry-over and prior-thread context. Paginates over threads.
348
- *
349
- * Outdated counts as settled because GitHub, unlike GitLab, does not
350
- * auto-resolve a thread when the line it anchors to changes: fixing a finding
351
- * flips the thread to outdated but leaves `isResolved` false until someone
352
- * manually resolves it. Treating outdated as settled mirrors GitLab's
353
- * "automatically resolve outdated diff discussions" behaviour, so a fixed
354
- * finding stops being re-listed under "Still open from earlier reviews" (#133).
355
- */
356
- async listSettledReviewCommentIds(owner, repo, pull) {
357
- const settled = /* @__PURE__ */ new Set();
358
- let cursor = null;
359
- let hasNext = true;
360
- while (hasNext) {
361
- const threads = (await this.graphql(REVIEW_THREADS_QUERY, {
362
- owner,
363
- repo,
364
- pull,
365
- cursor
366
- })).repository?.pullRequest?.reviewThreads;
367
- if (!threads) break;
368
- for (const thread of threads.nodes ?? []) {
369
- if (!thread.isResolved && !thread.isOutdated) continue;
370
- for (const comment of thread.comments?.nodes ?? []) if (typeof comment.databaseId === "number") settled.add(comment.databaseId);
371
- }
372
- hasNext = threads.pageInfo?.hasNextPage ?? false;
373
- cursor = threads.pageInfo?.endCursor ?? null;
374
- if (!cursor) hasNext = false;
375
- }
376
- return settled;
1054
+ const plugins = Array.isArray(manifest.plugins) ? manifest.plugins : [];
1055
+ const entry = plugins.find((p) => p && p.name === spec.plugin);
1056
+ if (!entry) {
1057
+ const available = plugins.map((p) => typeof p?.name === "string" ? p.name : null).filter((n) => Boolean(n));
1058
+ throw new ConfigError(`Plugin "${spec.plugin}" not found in marketplace "${mp.name}"`, { hint: available.length ? `Available plugins: ${available.join(", ")}.` : "The marketplace lists no plugins." });
1059
+ }
1060
+ const pluginDir = resolvePluginDir(repoDir, manifest, entry.source, mp, spec);
1061
+ const realPluginDir = await resolveInside(realRoot, pluginDir, ref);
1062
+ const bases = computeSkillBases(repoDir, pluginDir, entry, entry.strict !== false && realPluginDir ? await readPluginManifest(realRoot, realPluginDir, ref) : null, ref);
1063
+ const skill = await findSkillInBases(realRoot, bases, pluginDir, spec.skill, ref);
1064
+ 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.` });
1065
+ return skill;
1066
+ }
1067
+ /**
1068
+ * Read a plugin's optional `.claude-plugin/plugin.json`. A missing file returns
1069
+ * `null` (the manifest is optional), but a malformed one throws a `ConfigError`
1070
+ * rather than being silently ignored — otherwise a typo would quietly drop the
1071
+ * plugin's declared `skills` paths.
1072
+ */
1073
+ async function readPluginManifest(realRoot, pluginDir, ref) {
1074
+ const raw = await readTextInside(realRoot, join(pluginDir, ".claude-plugin", "plugin.json"), ref);
1075
+ if (raw === null) return null;
1076
+ try {
1077
+ return JSON.parse(raw);
1078
+ } catch (error) {
1079
+ throw new ConfigError(`Malformed plugin.json for "${ref}"`, {
1080
+ cause: error,
1081
+ hint: "The plugin's .claude-plugin/plugin.json is not valid JSON. Fix it in the marketplace repository."
1082
+ });
377
1083
  }
378
- };
1084
+ }
1085
+ /** Normalize a `skills`/`commands` manifest field (string | string[]) to a path list. */
1086
+ function toPathArray(value) {
1087
+ return (typeof value === "string" ? [value] : Array.isArray(value) ? value : []).filter((v) => typeof v === "string").map((v) => v.trim()).filter(Boolean);
1088
+ }
1089
+ /**
1090
+ * Compute the directories to search for a named skill, per the plugin schema:
1091
+ * the default `<plugin>/skills/` scan plus any directories declared in the
1092
+ * `skills` field of the marketplace entry and (unless `strict: false`) the
1093
+ * plugin's own `plugin.json`. When the plugin source resolves to the marketplace
1094
+ * root and the entry lists specific `skills` subdirectories, those replace the
1095
+ * default scan rather than adding to it.
1096
+ */
1097
+ function computeSkillBases(repoDir, pluginDir, entry, pluginManifest, ref) {
1098
+ const strict = entry.strict !== false;
1099
+ const entrySkills = toPathArray(entry.skills);
1100
+ const custom = (strict ? [...entrySkills, ...toPathArray(pluginManifest?.skills)] : entrySkills).map((p) => resolveUnderPlugin(repoDir, pluginDir, p, ref));
1101
+ const bases = resolve(pluginDir) === resolve(repoDir) && entrySkills.length > 0 ? custom : [join(pluginDir, "skills"), ...custom];
1102
+ return [...new Set(bases)];
1103
+ }
1104
+ /** Resolve a plugin-relative `skills` path to an absolute dir, guarding escapes. */
1105
+ function resolveUnderPlugin(repoDir, pluginDir, rel, ref) {
1106
+ let p = rel.trim();
1107
+ if (p === "." || p === "./") p = "";
1108
+ else if (p.startsWith("./")) p = p.slice(2);
1109
+ const dir = join(pluginDir, p);
1110
+ ensureInside(repoDir, dir, ref);
1111
+ return dir;
1112
+ }
1113
+ /**
1114
+ * Find `skillName` across the candidate base directories. Each base is either a
1115
+ * container of skill sub-directories (`<base>/<skill>/SKILL.md`) or a single
1116
+ * skill directory (`<base>/SKILL.md`, matched by its frontmatter `name`). Falls
1117
+ * back to a single `SKILL.md` at the plugin root (single-skill plugins).
1118
+ *
1119
+ * Every candidate is verified with {@link loadSkillIfInside} to resolve through
1120
+ * symlinks and reject any directory that escapes the cloned marketplace before a
1121
+ * file is read.
1122
+ */
1123
+ async function findSkillInBases(realRoot, bases, pluginDir, skillName, ref) {
1124
+ for (const base of bases) {
1125
+ const container = await loadSkillIfInside(realRoot, join(base, skillName), ref);
1126
+ if (container) return container;
1127
+ const single = await loadSkillIfInside(realRoot, base, ref);
1128
+ if (single && single.name === skillName) return single;
1129
+ }
1130
+ const root = await loadSkillIfInside(realRoot, pluginDir, ref);
1131
+ return root && root.name === skillName ? root : null;
1132
+ }
1133
+ /**
1134
+ * Resolve `dir` through symlinks and confirm it stays within `realRoot`. Returns
1135
+ * the real path, or `null` when the directory does not exist (nothing to read).
1136
+ * A directory that resolves — via `..` or a symlink — outside the marketplace
1137
+ * throws a `ConfigError`, so a malicious manifest cannot reach outside the clone.
1138
+ */
1139
+ async function resolveInside(realRoot, dir, ref) {
1140
+ let real;
1141
+ try {
1142
+ real = await realpath(dir);
1143
+ } catch {
1144
+ return null;
1145
+ }
1146
+ assertInside(realRoot, real, ref);
1147
+ return real;
1148
+ }
1149
+ /** Throw if `real` (an already-resolved real path) is not within `realRoot`. */
1150
+ function assertInside(realRoot, real, ref) {
1151
+ 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." });
1152
+ }
1153
+ /**
1154
+ * Read a file only if its real path (following any symlink on the file itself,
1155
+ * not just its parent directory) stays within `realRoot`. Returns `null` when
1156
+ * the file does not exist; throws a `ConfigError` when it resolves outside the
1157
+ * marketplace, so a symlinked `SKILL.md` / manifest cannot exfiltrate an outside
1158
+ * file's contents.
1159
+ */
1160
+ async function readTextInside(realRoot, filePath, ref) {
1161
+ let real;
1162
+ try {
1163
+ real = await realpath(filePath);
1164
+ } catch {
1165
+ return null;
1166
+ }
1167
+ assertInside(realRoot, real, ref);
1168
+ return readFile(real, "utf8");
1169
+ }
1170
+ /**
1171
+ * Load a skill from `dir` only if both the directory AND its `SKILL.md` file
1172
+ * resolve within `realRoot`. Guarding the file (not just the directory) closes
1173
+ * the case where a legitimate in-repo dir holds a `SKILL.md` symlinked outside.
1174
+ */
1175
+ async function loadSkillIfInside(realRoot, dir, ref) {
1176
+ const real = await resolveInside(realRoot, dir, ref);
1177
+ if (!real) return null;
1178
+ if (await readTextInside(realRoot, join(real, "SKILL.md"), ref) === null) return null;
1179
+ return loadSkillFromDir(real, "marketplace");
1180
+ }
1181
+ /**
1182
+ * Resolve a plugin entry's `source` to an absolute directory inside the cloned
1183
+ * marketplace. Honors `metadata.pluginRoot` for bare (non-`./`) sources, per the
1184
+ * marketplace schema. Remote sources (github/url/git-subdir/npm) are rejected —
1185
+ * we only resolve plugins that live in the marketplace repository itself.
1186
+ */
1187
+ function resolvePluginDir(repoDir, manifest, source, mp, spec) {
1188
+ const ref = `${mp.name}:${spec.plugin}/${spec.skill}`;
1189
+ 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." });
1190
+ let rel = source.trim();
1191
+ if (rel === "." || rel === "./") rel = "";
1192
+ else if (rel.startsWith("./")) rel = rel.slice(2);
1193
+ else {
1194
+ const pluginRoot = typeof manifest.metadata?.pluginRoot === "string" ? manifest.metadata.pluginRoot.trim() : "";
1195
+ const root = pluginRoot.startsWith("./") ? pluginRoot.slice(2) : pluginRoot;
1196
+ rel = root ? `${root.replace(/\/+$/, "")}/${rel}` : rel;
1197
+ }
1198
+ const dir = join(repoDir, rel);
1199
+ ensureInside(repoDir, dir, ref);
1200
+ return dir;
1201
+ }
1202
+ /** Guard against `..`/symlink escapes: `target` must stay within `root`. */
1203
+ function ensureInside(root, target, ref) {
1204
+ const rootResolved = resolve(root);
1205
+ const targetResolved = resolve(target);
1206
+ 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." });
1207
+ }
379
1208
  //#endregion
380
1209
  //#region src/fingerprints.ts
381
1210
  /**
@@ -560,7 +1389,7 @@ function buildSummaryBody(summary, costFooter, options = {}) {
560
1389
  return `${withFooter}\n\n${buildSummaryHistoryBlock(historyEntries)}`;
561
1390
  }
562
1391
  function buildReviewedCommitFooter(commitSha) {
563
- return `Reviewed by ${PRODUCT_LINK} v0.9.2 for commit ${commitSha}.`;
1392
+ return `Reviewed by ${PRODUCT_LINK} v0.9.4 for commit ${commitSha}.`;
564
1393
  }
565
1394
  function extractReviewedCommitSha(body) {
566
1395
  return REVIEWED_COMMIT_FOOTER_PATTERN.exec(body)?.[1] ?? null;
@@ -896,6 +1725,7 @@ var RESERVED_ENV_SUFFIXES = [
896
1725
  "FORCE_REVIEW",
897
1726
  "VERBOSE",
898
1727
  "SKILLS",
1728
+ "MARKETPLACES",
899
1729
  "REFRESH_SKILLS",
900
1730
  "THINKING_LEVEL"
901
1731
  ];
@@ -964,7 +1794,7 @@ var BOOLEAN_FLAGS = new Set([
964
1794
  "help",
965
1795
  "version"
966
1796
  ]);
967
- var MULTI_FLAGS = new Set(["skill"]);
1797
+ var MULTI_FLAGS = new Set(["skill", "marketplace"]);
968
1798
  function parseArgs(argv) {
969
1799
  const args = {};
970
1800
  for (let i = 0; i < argv.length; i += 1) {
@@ -1084,6 +1914,16 @@ function resolveSkills(args, env) {
1084
1914
  return [];
1085
1915
  }
1086
1916
  /**
1917
+ * Resolve marketplace declarations from `--marketplace` (repeatable, preferred)
1918
+ * or the comma-separated `CODE_REVIEW_MARKETPLACES`. Each entry is
1919
+ * `<name>=<[format:]url[#ref]>`; parsing throws a `ConfigError` on malformed
1920
+ * entries, reserved names, or unknown formats (fail fast on misconfiguration).
1921
+ */
1922
+ function resolveMarketplaces(args, env) {
1923
+ const argMarketplace = args.marketplace;
1924
+ 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));
1925
+ }
1926
+ /**
1087
1927
  * Resolve the model pool from `--model-pool` (preferred) or
1088
1928
  * `CODE_REVIEW_MODEL_POOL`. Both are comma-separated `provider/modelId` lists.
1089
1929
  * Entries are trimmed and empty entries dropped. Returns `[]` when unset, which
@@ -1248,6 +2088,7 @@ function resolveConfig(argv = process.argv.slice(2), env = process.env) {
1248
2088
  verbose: toBoolean(args.verbose) || toBoolean(env.CODE_REVIEW_VERBOSE),
1249
2089
  cwd: String(args.cwd ?? process.cwd()),
1250
2090
  skills: resolveSkills(args, env),
2091
+ marketplaces: resolveMarketplaces(args, env),
1251
2092
  refreshGitSkills: toBoolean(env.CODE_REVIEW_REFRESH_SKILLS)
1252
2093
  };
1253
2094
  }
@@ -1358,7 +2199,7 @@ function createDiagnosticContext(phase, config, runId, overrides = {}) {
1358
2199
  phase,
1359
2200
  project: config.project,
1360
2201
  mr: config.mr,
1361
- gitlabUrl: config.gitlabUrl,
2202
+ gitlabUrl: config.platform === "github" ? config.githubServerUrl : config.gitlabUrl,
1362
2203
  platform: config.platform,
1363
2204
  cwd: config.cwd,
1364
2205
  model: config.model,
@@ -1392,179 +2233,17 @@ function traceDiagnosticPhase(phase, config, runId, operation, overrides = {}) {
1392
2233
  function toDiagnosticError(error, secretValues = []) {
1393
2234
  if (error instanceof Error) {
1394
2235
  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
- };
2236
+ const timeout = "timeout" in error && error.timeout === true ? true : void 0;
2237
+ const status = "status" in error && typeof error.status === "number" ? error.status : void 0;
2238
+ return {
2239
+ name: error.name,
2240
+ message: scrubSecrets(error.message, secretValues),
2241
+ code,
2242
+ timeout,
2243
+ status
2244
+ };
2245
+ }
2246
+ return { message: scrubSecrets(String(error), secretValues) };
1568
2247
  }
1569
2248
  //#endregion
1570
2249
  //#region src/git-tool.ts
@@ -2905,452 +3584,117 @@ function parseInlineSection(markdown, out, warnings) {
2905
3584
  flush();
2906
3585
  if (sawBodyBeforeHeader) warnings.push("Ignored text in the inline comments section before the first parseable comment header.");
2907
3586
  }
2908
- function parseReviewMarkdownWithWarnings(markdown) {
2909
- const comments = [];
2910
- const warnings = [];
2911
- const { summary, malformed } = parseJsonComments(markdown, comments, warnings);
2912
- parseInlineSection(markdown, comments, warnings);
2913
- if (malformed && comments.length > 0) {
2914
- warnings.push(`A reviewer JSON block was unparseable (${malformed.reason}), but ${comments.length} comment(s) were recovered from other sections; continuing.`);
2915
- return {
2916
- comments,
2917
- summary,
2918
- warnings,
2919
- malformed: null
2920
- };
2921
- }
2922
- 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
3152
- };
3153
- }
3154
- /**
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.
3165
- */
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 {
3587
+ function parseReviewMarkdownWithWarnings(markdown) {
3588
+ const comments = [];
3589
+ const warnings = [];
3590
+ const { summary, malformed } = parseJsonComments(markdown, comments, warnings);
3591
+ parseInlineSection(markdown, comments, warnings);
3592
+ if (malformed && comments.length > 0) {
3593
+ warnings.push(`A reviewer JSON block was unparseable (${malformed.reason}), but ${comments.length} comment(s) were recovered from other sections; continuing.`);
3172
3594
  return {
3173
- protocol: "git",
3174
- url: raw,
3175
- ref: "",
3176
- subpath: ""
3595
+ comments,
3596
+ summary,
3597
+ warnings,
3598
+ malformed: null
3177
3599
  };
3178
3600
  }
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
3601
  return {
3190
- protocol: "git",
3191
- url,
3192
- ref: fragment.slice(0, slashIdx),
3193
- subpath: fragment.slice(slashIdx + 1)
3602
+ comments,
3603
+ summary,
3604
+ warnings,
3605
+ malformed
3194
3606
  };
3195
3607
  }
3608
+ function parseReviewMarkdown(markdown) {
3609
+ return parseReviewMarkdownWithWarnings(markdown).comments;
3610
+ }
3611
+ //#endregion
3612
+ //#region src/prior-threads.ts
3613
+ var FINGERPRINT_MARKER_RE = new RegExp(FINGERPRINT_MARKER_PATTERN, "i");
3196
3614
  /**
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.
3615
+ * Returns true when the note body contains a code-review fingerprint marker
3616
+ * (current or legacy prefix).
3617
+ * Used to identify notes posted by the bot without needing a getCurrentUser() call.
3200
3618
  */
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;
3209
- }
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");
3619
+ function isBotNote(note) {
3620
+ return FINGERPRINT_MARKER_RE.test(note.body ?? "");
3215
3621
  }
3216
3622
  /**
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.
3623
+ * Parses the `+++ b/<path>` lines from a unified diff and returns the set of
3624
+ * new file paths. `/dev/null` (deleted files) is excluded.
3219
3625
  */
3220
- function gitSkillCacheKey(url, ref) {
3221
- return createHash("sha256").update(`${url}#${ref}`).digest("hex").slice(0, 16);
3626
+ function extractChangedFiles(diff) {
3627
+ const files = /* @__PURE__ */ new Set();
3628
+ for (const line of diff.split("\n")) {
3629
+ const match = line.match(/^\+\+\+ b\/(.+)$/);
3630
+ if (match && match[1] !== "/dev/null") files.add(match[1]);
3631
+ }
3632
+ return files;
3222
3633
  }
3223
3634
  /**
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`.
3635
+ * Returns the line number for a discussion note's position.
3636
+ * Prefers the new-side line (`new_line`) then falls back to `old_line`.
3228
3637
  */
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 });
3638
+ function positionLine$1(note) {
3639
+ return note.position?.new_line ?? note.position?.old_line ?? null;
3254
3640
  }
3255
3641
  /**
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.
3642
+ * Returns the file path for a discussion note's position.
3643
+ * Prefers the new path then falls back to the old path.
3260
3644
  */
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
3296
- });
3297
- throw error;
3298
- }
3299
- return repoDir;
3645
+ function positionFile$1(note) {
3646
+ return note.position?.new_path ?? note.position?.old_path ?? null;
3300
3647
  }
3301
3648
  /**
3302
- * Load a skill by its spec string (`code-review`, `npm:@scope/pkg`, `file:./path`,
3303
- * `git:https://…`, …).
3649
+ * Extracts prior review threads from existing MR discussions that are relevant
3650
+ * to the current diff.
3304
3651
  *
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
3652
+ * A thread is included when:
3653
+ * - It contains at least one bot note (identified by fingerprint marker).
3654
+ * - It contains at least one non-system human reply after the bot note.
3655
+ * - The thread's file appears in `changedFiles`.
3311
3656
  *
3312
- * Throws a `ConfigError` if the spec cannot be resolved or the resolved
3313
- * directory does not contain a valid `SKILL.md`.
3657
+ * Resolved threads are included but marked with `resolved: true` so the
3658
+ * reviewer can reference them without re-raising the concern.
3314
3659
  */
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`
3660
+ function extractPriorThreads(discussions, changedFiles) {
3661
+ const threads = [];
3662
+ for (const discussion of discussions) {
3663
+ const notes = discussion.notes ?? [];
3664
+ const botNoteIndex = notes.findIndex(isBotNote);
3665
+ if (botNoteIndex === -1) continue;
3666
+ const botNote = notes[botNoteIndex];
3667
+ const file = positionFile$1(botNote);
3668
+ if (!file || !changedFiles.has(file)) continue;
3669
+ const replies = notes.slice(botNoteIndex + 1).filter((n) => !n.system && (n.body?.trim() ?? "")).filter((n) => !isBotNote(n)).map((n) => n.body?.trim() ?? "");
3670
+ if (replies.length === 0) continue;
3671
+ const resolved = notes.some((n) => n.resolved === true);
3672
+ threads.push({
3673
+ file,
3674
+ line: positionLine$1(botNote),
3675
+ resolved,
3676
+ botComment: normalizeBody(botNote.body ?? ""),
3677
+ replies
3349
3678
  });
3350
3679
  }
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;
3680
+ return threads;
3681
+ }
3682
+ /**
3683
+ * Renders a `<prior_review_feedback>` XML block from a list of prior threads.
3684
+ * Returns an empty string when `threads` is empty.
3685
+ */
3686
+ function renderPriorThreadsBlock(threads) {
3687
+ if (threads.length === 0) return "";
3688
+ return `<prior_review_feedback>\n${threads.map((t) => {
3689
+ return ` <thread ${[
3690
+ `file="${t.file}"`,
3691
+ t.line !== null ? `line="${t.line}"` : null,
3692
+ `resolved="${t.resolved}"`
3693
+ ].filter(Boolean).join(" ")}>\n${` <comment>${escapeXml(t.botComment)}</comment>`}\n${t.replies.map((r) => ` <reply>${escapeXml(r)}</reply>`).join("\n")}\n </thread>`;
3694
+ }).join("\n")}\n</prior_review_feedback>`;
3695
+ }
3696
+ function escapeXml(text) {
3697
+ return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&apos;");
3354
3698
  }
3355
3699
  //#endregion
3356
3700
  //#region src/skipped-retrieval.ts
@@ -3689,10 +4033,14 @@ async function loadReviewContext(cwd, skillNames = [], warn, options = {}) {
3689
4033
  walkUpContextFiles(cwd, REVIEW_RULE_FILES, gitRoot),
3690
4034
  loadAutoDiscoveredSkills(cwd, gitRoot, warn)
3691
4035
  ]);
4036
+ const registry = buildMarketplaceRegistry(options.marketplaces ?? []);
4037
+ const knownMarketplaces = new Set(registry.keys());
3692
4038
  const skills = [...discovered];
3693
4039
  const discoveredNames = new Set(discovered.map((s) => s.name));
3694
4040
  const named = await Promise.all(skillNames.filter((n) => !discoveredNames.has(n)).map(async (n) => {
3695
4041
  try {
4042
+ const spec = parseSkillSpec(n, knownMarketplaces);
4043
+ if (spec.protocol === "marketplace") return await loadMarketplaceSkill(spec, registry, { refresh: options.refreshGitSkills });
3696
4044
  return await loadNamedSkill(n, cwd, { refresh: options.refreshGitSkills });
3697
4045
  } catch (error) {
3698
4046
  warn?.(`Skipping skill "${n}": ${formatError(error)}`);
@@ -4329,7 +4677,10 @@ async function runReview(config, options) {
4329
4677
  retrieved: retrievableSkipped.length > 0
4330
4678
  };
4331
4679
  }
4332
- const context = await loadReviewContext(cwd, config.skills, (msg) => logger.warn(msg), { refreshGitSkills: config.refreshGitSkills });
4680
+ const context = await loadReviewContext(cwd, config.skills, (msg) => logger.warn(msg), {
4681
+ refreshGitSkills: config.refreshGitSkills,
4682
+ marketplaces: config.marketplaces
4683
+ });
4333
4684
  const systemPrompt = buildJSONSystemPrompt(context, minSeverity);
4334
4685
  const userPrompt = buildUserPrompt(promptDiff, promptSkippedFiles, options.commitLog, options.priorThreads, options.intent, promptCoverage, retrievableSkipped, diskMode, commitsMode ? { sinceRef: options.sinceRef } : void 0);
4335
4686
  const skillNames = context.skills.map((s) => s.name);
@@ -5265,7 +5616,7 @@ async function loadDefaultRuntime() {
5265
5616
  const [sdkNode, resources, semconv] = modules;
5266
5617
  const serviceResource = resources.resourceFromAttributes({
5267
5618
  [semconv.ATTR_SERVICE_NAME ?? "service.name"]: SERVICE_NAME,
5268
- [semconv.ATTR_SERVICE_VERSION ?? "service.version"]: "0.9.2"
5619
+ [semconv.ATTR_SERVICE_VERSION ?? "service.version"]: "0.9.4"
5269
5620
  });
5270
5621
  applyOtelExporterDefaults(process.env);
5271
5622
  const sdk = new sdkNode.NodeSDK({ resource: resources.defaultResource().merge(serviceResource) });
@@ -5737,7 +6088,7 @@ function boldCommentTitle(body) {
5737
6088
  */
5738
6089
  function buildCommentBody(body, commitSha, confidence) {
5739
6090
  const confidenceLine = `_Confidence: ${confidence}._`;
5740
- const footer = `<sub>Reviewed by ${PRODUCT_LINK} v0.9.2 for commit ${commitSha}.</sub>`;
6091
+ const footer = `<sub>Reviewed by ${PRODUCT_LINK} v0.9.4 for commit ${commitSha}.</sub>`;
5741
6092
  return `${boldCommentTitle(body.trim())}\n\n${confidenceLine}\n\n---\n\n${footer}`;
5742
6093
  }
5743
6094
  function buildPayload(comment, body, refs, resolved) {
@@ -6844,10 +7195,10 @@ async function main(argv = process.argv.slice(2)) {
6844
7195
  return;
6845
7196
  }
6846
7197
  if (argv.includes("--version") || argv.includes("-v")) {
6847
- console.log("0.9.2");
7198
+ console.log("0.9.4");
6848
7199
  return;
6849
7200
  }
6850
- process.stderr.write(`[code-review] @weareikko/code-review v0.9.2\n`);
7201
+ process.stderr.write(`[code-review] @weareikko/code-review v0.9.4\n`);
6851
7202
  assertNodeVersion();
6852
7203
  applyCodeReviewEnvPrefix();
6853
7204
  applyDefaultCacheRetention();
@@ -6868,6 +7219,6 @@ if (isDirectRun()) main().catch((error) => {
6868
7219
  process.exitCode = 1;
6869
7220
  });
6870
7221
  //#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 };
7222
+ 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
7223
 
6873
- //# sourceMappingURL=cli-D47EQ1qq.js.map
7224
+ //# sourceMappingURL=cli-C3kNr0rX.js.map