comprehende 0.5.4 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/README.md +1 -1
  2. package/dist/api/agent-md.js +10 -9
  3. package/dist/api/live.js +16 -2
  4. package/dist/cli/args.js +1 -1
  5. package/dist/cli/commands.js +5 -2
  6. package/dist/cli/main.js +5 -6
  7. package/dist/git/exec.js +22 -0
  8. package/dist/git/lfs.js +2 -6
  9. package/dist/git/repo.js +9 -3
  10. package/dist/review/pins.js +30 -0
  11. package/dist/schema/parse.js +206 -20
  12. package/dist/schema/skill-sync.js +3 -1
  13. package/dist/schema/source.js +89 -0
  14. package/dist/schema/types.js +8 -0
  15. package/dist/ui/assets/bricolage-grotesque-latin-ext-opsz-normal-IcJDqblK.woff2 +0 -0
  16. package/dist/ui/assets/bricolage-grotesque-latin-opsz-normal-Cre6nC2_.woff2 +0 -0
  17. package/dist/ui/assets/bricolage-grotesque-vietnamese-opsz-normal-D9N6E8K1.woff2 +0 -0
  18. package/dist/ui/assets/index-C1EoRByu.css +1 -0
  19. package/dist/ui/assets/index-su33x8uU.js +1713 -0
  20. package/dist/ui/index.html +2 -2
  21. package/package.json +3 -3
  22. package/skills/comprehende/SKILL.md +33 -15
  23. package/skills/comprehende/references/example.md +10 -5
  24. package/skills/comprehende/references/review.schema.json +95 -27
  25. package/dist/ui/assets/index-BQTydqt9.css +0 -1
  26. package/dist/ui/assets/index-COQ0_ea8.js +0 -1713
  27. package/dist/ui/assets/source-serif-4-cyrillic-400-normal-B8Z6Jvzv.woff +0 -0
  28. package/dist/ui/assets/source-serif-4-cyrillic-400-normal-C44pakCn.woff2 +0 -0
  29. package/dist/ui/assets/source-serif-4-cyrillic-600-normal-BDP5YDHD.woff2 +0 -0
  30. package/dist/ui/assets/source-serif-4-cyrillic-600-normal-DFnC3vAY.woff +0 -0
  31. package/dist/ui/assets/source-serif-4-cyrillic-ext-400-normal-CWV284fw.woff2 +0 -0
  32. package/dist/ui/assets/source-serif-4-cyrillic-ext-400-normal-DLkyKLvH.woff +0 -0
  33. package/dist/ui/assets/source-serif-4-cyrillic-ext-600-normal-D_E8FDgP.woff2 +0 -0
  34. package/dist/ui/assets/source-serif-4-cyrillic-ext-600-normal-DiT2IGQm.woff +0 -0
  35. package/dist/ui/assets/source-serif-4-greek-400-normal-BccYJ-vZ.woff +0 -0
  36. package/dist/ui/assets/source-serif-4-greek-400-normal-Cw5-AHNN.woff2 +0 -0
  37. package/dist/ui/assets/source-serif-4-greek-600-normal-DdRkKr-1.woff2 +0 -0
  38. package/dist/ui/assets/source-serif-4-greek-600-normal-DhXQw1Kl.woff +0 -0
  39. package/dist/ui/assets/source-serif-4-latin-400-normal-DJ5YJwmz.woff2 +0 -0
  40. package/dist/ui/assets/source-serif-4-latin-400-normal-Dn3IlU-Z.woff +0 -0
  41. package/dist/ui/assets/source-serif-4-latin-600-normal-DMD1h6_f.woff +0 -0
  42. package/dist/ui/assets/source-serif-4-latin-600-normal-DouSKlru.woff2 +0 -0
  43. package/dist/ui/assets/source-serif-4-latin-ext-400-normal-CEpydyUl.woff +0 -0
  44. package/dist/ui/assets/source-serif-4-latin-ext-400-normal-Cp7z-ARB.woff2 +0 -0
  45. package/dist/ui/assets/source-serif-4-latin-ext-600-normal-0sIUyHMO.woff2 +0 -0
  46. package/dist/ui/assets/source-serif-4-latin-ext-600-normal-C6NNqpJh.woff +0 -0
  47. package/dist/ui/assets/source-serif-4-vietnamese-400-normal-BbG3LG1A.woff2 +0 -0
  48. package/dist/ui/assets/source-serif-4-vietnamese-400-normal-DR1UuFon.woff +0 -0
  49. package/dist/ui/assets/source-serif-4-vietnamese-600-normal-B0WJbuqT.woff +0 -0
  50. package/dist/ui/assets/source-serif-4-vietnamese-600-normal-CznyfUKR.woff2 +0 -0
package/README.md CHANGED
@@ -42,7 +42,7 @@ This tool is for preventing this cognitive surrender while trying to maintain mo
42
42
 
43
43
  `comprehende serve` and `comprehende export` share one UI and one git payload. Serve resolves refs to commit SHAs when it starts, then computes those payloads from the objects on each request. Export writes the same JSON (and image bytes) next to the UI so any static file server can host the review.
44
44
 
45
- `pnpm dev` and `pnpm exec` run with this package as cwd, so they only make sense when _this_ repo is the one under review. To review a different project from a checkout, `cd` into it and run `npx comprehende@0.5.4` (or `node /path/to/comprehende/dist/cli/main.js` after `pnpm build`).
45
+ `pnpm dev` and `pnpm exec` run with this package as cwd, so they only make sense when _this_ repo is the one under review. To review a different project from a checkout, `cd` into it and run `npx comprehende@0.6.0` (or `node /path/to/comprehende/dist/cli/main.js` after `pnpm build`).
46
46
 
47
47
  ## Release
48
48
 
@@ -23,8 +23,9 @@ function overviewAgentMd(review) {
23
23
  "Answer questions about this git change.",
24
24
  overviewSteps(review),
25
25
  pinBlock(review, { commits: true }),
26
- ticketsBlock(review),
26
+ sourcesBlock(review),
27
27
  coverageBlock(review),
28
+ joinBlocks(["The title:", review.document.title]),
28
29
  review.document.why !== undefined ? joinBlocks(["The why:", review.document.why]) : null,
29
30
  joinBlocks([`The what (${sizeLabel(review.document.size)}):`, review.document.summary]),
30
31
  reviewConcernsBlock(review),
@@ -78,17 +79,17 @@ function pinBlock(review, options) {
78
79
  commits,
79
80
  ]);
80
81
  }
81
- function ticketsBlock(review) {
82
- const tickets = review.document.tickets ?? [];
83
- if (tickets.length === 0) {
82
+ function sourcesBlock(review) {
83
+ const sources = review.document.sources ?? [];
84
+ if (sources.length === 0) {
84
85
  return null;
85
86
  }
86
- const lines = tickets.map((ticket) => {
87
- const title = ticket.title !== undefined ? ` ${ticket.title}` : "";
88
- const url = ticket.url !== undefined ? `\n ${ticket.url}` : "";
89
- return `- ${ticket.id}${title}${url}`;
87
+ const lines = sources.map((source) => {
88
+ const gist = source.gist !== undefined ? ` ${source.gist}` : "";
89
+ const url = source.url !== undefined ? `\n ${source.url}` : "";
90
+ return `- ${source.kind} ${source.label}${gist}${url}`;
90
91
  });
91
- return ["Tickets:", ...lines].join("\n");
92
+ return ["Sources:", ...lines].join("\n");
92
93
  }
93
94
  function coverageBlock(review) {
94
95
  const lines = [];
package/dist/api/live.js CHANGED
@@ -6,8 +6,10 @@ import { GitError } from "../git/exec.js";
6
6
  import { listCommits } from "../git/log.js";
7
7
  import { pinRange, readRepoIdentity } from "../git/repo.js";
8
8
  import { showFile } from "../git/show.js";
9
- import { coverReview } from "../review/coverage.js";
9
+ import { coverReview, coverageErrors } from "../review/coverage.js";
10
+ import { commentPinErrors, staleCommentPins } from "../review/pins.js";
10
11
  import { isLockfilePath } from "../schema/lockfile.js";
12
+ import { sourceCitationErrors } from "../schema/source.js";
11
13
  import { AGENT_MD_MEDIA_TYPE, agentMd } from "./agent-md.js";
12
14
  import { ApiError } from "./error.js";
13
15
  export async function pinReviewSource(cwd, dataPath) {
@@ -18,6 +20,7 @@ export async function openReview(cwd, dataPath, pin) {
18
20
  const document = await loadDocument(dataPath);
19
21
  const range = pin ?? (await pinRange(cwd, document.source.baseRef, document.source.headRef));
20
22
  const { files, coverage } = await coverReview(cwd, document, range);
23
+ const pins = await staleCommentPins(cwd, document, range);
21
24
  const commits = await listCommits(cwd, range.baseSha, range.headSha);
22
25
  const repo = await readRepoIdentity(cwd);
23
26
  return {
@@ -33,12 +36,13 @@ export async function openReview(cwd, dataPath, pin) {
33
36
  },
34
37
  files,
35
38
  coverage,
39
+ staleCommentPins: pins,
36
40
  mergeBaseSha: range.mergeBaseSha,
37
41
  commits,
38
42
  };
39
43
  }
40
44
  export function reviewPayload(ctx) {
41
- const { document, repo, resolved, files, coverage, commits } = ctx;
45
+ const { document, repo, resolved, files, coverage, staleCommentPins, commits } = ctx;
42
46
  const lockfiles = lockfileFiles(files);
43
47
  return {
44
48
  document,
@@ -49,6 +53,7 @@ export function reviewPayload(ctx) {
49
53
  assignedHunks: coverage.assignedHunks,
50
54
  unassignedCount: coverage.unassigned.length,
51
55
  staleCount: coverage.stale.length,
56
+ staleSourceCount: staleCommentPins.length,
52
57
  },
53
58
  groups: coverage.groups
54
59
  .slice()
@@ -61,6 +66,7 @@ export function reviewPayload(ctx) {
61
66
  lookFor: group.group.lookFor ?? [],
62
67
  dependsOn: group.group.dependsOn ?? [],
63
68
  part: group.group.part,
69
+ sources: group.group.sources ?? [],
64
70
  suggestedOrder: group.group.suggestedOrder,
65
71
  hunkCount: group.hunks.length,
66
72
  staleCount: group.stale.length,
@@ -75,6 +81,7 @@ export function reviewPayload(ctx) {
75
81
  files: lockfiles.map((file) => file.path),
76
82
  },
77
83
  stale: coverage.stale,
84
+ staleSources: staleCommentPins,
78
85
  files: files.map((file) => {
79
86
  const entry = {
80
87
  path: file.path,
@@ -92,6 +99,13 @@ export function reviewPayload(ctx) {
92
99
  commits,
93
100
  };
94
101
  }
102
+ export function reviewProblems(ctx) {
103
+ return [
104
+ ...coverageErrors(ctx.coverage),
105
+ ...sourceCitationErrors(ctx.document),
106
+ ...commentPinErrors(ctx.staleCommentPins),
107
+ ];
108
+ }
95
109
  export function hunksPayload(ctx, groupId) {
96
110
  if (groupId === "") {
97
111
  throw new ApiError(400, "missing group");
package/dist/cli/args.js CHANGED
@@ -8,7 +8,7 @@ Commands:
8
8
  List hunk refs from live git (no patch text)
9
9
 
10
10
  validate --data <review.json>
11
- Check schema, ref resolution, and hunk coverage
11
+ Check schema, ref resolution, hunk coverage, and source citations
12
12
 
13
13
  serve --data <review.json> [--port <n>] [--open]
14
14
  Serve the local UI on 127.0.0.1 (pins commit SHAs at start)
@@ -3,7 +3,9 @@ import { isAbsolute, resolve } from "node:path";
3
3
  import { readHunkIndex, resolveSource } from "../git/diff.js";
4
4
  import { defaultBaseRef } from "../git/repo.js";
5
5
  import { coverReview, coverageErrors } from "../review/coverage.js";
6
+ import { commentPinErrors, staleCommentPins } from "../review/pins.js";
6
7
  import { parseReviewJson } from "../schema/parse.js";
8
+ import { sourceCitationErrors } from "../schema/source.js";
7
9
  export async function resolveRange(cwd, base, head) {
8
10
  const baseRef = base ?? (await defaultBaseRef(cwd));
9
11
  const headRef = head ?? "HEAD";
@@ -36,9 +38,10 @@ export function resolveOutPath(out, cwd) {
36
38
  }
37
39
  export async function cmdValidate(cwd, dataPath) {
38
40
  const document = await loadDocument(dataPath);
39
- await resolveSource(cwd, document.source.baseRef, document.source.headRef);
41
+ const resolved = await resolveSource(cwd, document.source.baseRef, document.source.headRef);
40
42
  const { coverage } = await coverReview(cwd, document);
41
- const errors = coverageErrors(coverage);
43
+ const pins = await staleCommentPins(cwd, document, resolved);
44
+ const errors = [...coverageErrors(coverage), ...sourceCitationErrors(document), ...commentPinErrors(pins)];
42
45
  if (errors.length > 0) {
43
46
  throw new Error(errors.join("\n\n"));
44
47
  }
package/dist/cli/main.js CHANGED
@@ -6,8 +6,7 @@ import { fileURLToPath } from "node:url";
6
6
  import { parseArgv, USAGE } from "./args.js";
7
7
  import { cmdIndex, cmdValidate, resolveDataPath, resolveOutPath } from "./commands.js";
8
8
  import { exportStaticSite } from "../api/snapshot.js";
9
- import { openReview, pinReviewSource } from "../api/live.js";
10
- import { coverageErrors } from "../review/coverage.js";
9
+ import { openReview, pinReviewSource, reviewProblems } from "../api/live.js";
11
10
  import { assertWorkTree } from "../git/repo.js";
12
11
  import { readPackageVersion } from "../package-root.js";
13
12
  import { startServer } from "../server/http.js";
@@ -44,7 +43,7 @@ export async function run(argv) {
44
43
  const dataPath = resolveDataPath(request.data, request.cwd);
45
44
  const pin = await pinReviewSource(request.cwd, dataPath);
46
45
  const ctx = await openReview(request.cwd, dataPath, pin);
47
- warnCoverage(ctx.coverage, "serve continues; git wins, unassigned/stale are visible");
46
+ warnReview(ctx, "serve continues; git wins, unassigned/stale are visible");
48
47
  const running = await startServer({ cwd: request.cwd, dataPath, port: request.port, pin });
49
48
  console.log(running.url);
50
49
  console.error(`serving ${dataPath} cwd=${request.cwd} localhost only`);
@@ -58,7 +57,7 @@ export async function run(argv) {
58
57
  const dataPath = resolveDataPath(request.data, request.cwd);
59
58
  const outDir = resolveOutPath(request.out, request.cwd);
60
59
  const ctx = await openReview(request.cwd, dataPath);
61
- warnCoverage(ctx.coverage, "export continues; git wins, unassigned/stale are visible");
60
+ warnReview(ctx, "export continues; git wins, unassigned/stale are visible");
62
61
  const result = await exportStaticSite({ cwd: request.cwd, dataPath, outDir, ctx });
63
62
  console.log(result.outDir);
64
63
  console.error(`exported ${dataPath} ${result.apiFiles.length} api files no git in the folder`);
@@ -71,8 +70,8 @@ export async function run(argv) {
71
70
  return 1;
72
71
  }
73
72
  }
74
- function warnCoverage(coverage, note) {
75
- const problems = coverageErrors(coverage);
73
+ function warnReview(ctx, note) {
74
+ const problems = reviewProblems(ctx);
76
75
  if (problems.length > 0) {
77
76
  console.error(`coverage issues (${note}):\n${problems.join("\n\n")}`);
78
77
  }
package/dist/git/exec.js CHANGED
@@ -1,6 +1,26 @@
1
1
  import { execFile } from "node:child_process";
2
2
  import { promisify } from "node:util";
3
3
  const execFileAsync = promisify(execFile);
4
+ const GIT_DIR_VARS = new Set([
5
+ "GIT_DIR",
6
+ "GIT_WORK_TREE",
7
+ "GIT_INDEX_FILE",
8
+ "GIT_OBJECT_DIRECTORY",
9
+ "GIT_ALTERNATE_OBJECT_DIRECTORIES",
10
+ "GIT_COMMON_DIR",
11
+ "GIT_PREFIX",
12
+ ]);
13
+ /** Drop inherited git dir vars so `cwd` is the repository, including linked worktrees. */
14
+ export function gitEnv(base = process.env) {
15
+ const env = {};
16
+ for (const [key, value] of Object.entries(base)) {
17
+ if (value === undefined || GIT_DIR_VARS.has(key.toUpperCase())) {
18
+ continue;
19
+ }
20
+ env[key] = value;
21
+ }
22
+ return env;
23
+ }
4
24
  export class GitError extends Error {
5
25
  args;
6
26
  stderr;
@@ -17,6 +37,7 @@ export async function git(cwd, args, opts) {
17
37
  try {
18
38
  const { stdout } = await execFileAsync("git", ["-c", "core.quotepath=false", ...args], {
19
39
  cwd,
40
+ env: gitEnv(),
20
41
  encoding: "utf8",
21
42
  maxBuffer: 64 * 1024 * 1024,
22
43
  });
@@ -34,6 +55,7 @@ export async function gitBuffer(cwd, args, opts) {
34
55
  try {
35
56
  const { stdout } = await execFileAsync("git", ["-c", "core.quotepath=false", ...args], {
36
57
  cwd,
58
+ env: gitEnv(),
37
59
  encoding: "buffer",
38
60
  maxBuffer: 64 * 1024 * 1024,
39
61
  ...(opts?.input !== undefined ? { input: Buffer.from(opts.input) } : {}),
package/dist/git/lfs.js CHANGED
@@ -1,8 +1,8 @@
1
1
  import { readFile } from "node:fs/promises";
2
2
  import { existsSync } from "node:fs";
3
- import { isAbsolute, join, resolve } from "node:path";
3
+ import { join } from "node:path";
4
4
  import { LFS_POINTER_VERSION } from "../schema/image.js";
5
- import { git } from "./exec.js";
5
+ import { gitCommonDir } from "./repo.js";
6
6
  const POINTER_MAX = 1024;
7
7
  export function parseLfsPointer(bytes) {
8
8
  if (bytes.length === 0 || bytes.length > POINTER_MAX) {
@@ -25,10 +25,6 @@ export function parseLfsPointer(bytes) {
25
25
  export function lfsObjectPath(gitCommonDir, oid) {
26
26
  return join(gitCommonDir, "lfs", "objects", oid.slice(0, 2), oid.slice(2, 4), oid);
27
27
  }
28
- export async function gitCommonDir(cwd) {
29
- const raw = (await git(cwd, ["rev-parse", "--git-common-dir"])).trim();
30
- return isAbsolute(raw) ? raw : resolve(cwd, raw);
31
- }
32
28
  export async function readLfsObject(cwd, pointer) {
33
29
  const common = await gitCommonDir(cwd);
34
30
  const stored = lfsObjectPath(common, pointer.oid);
package/dist/git/repo.js CHANGED
@@ -1,4 +1,4 @@
1
- import { basename } from "node:path";
1
+ import { basename, dirname, isAbsolute, resolve } from "node:path";
2
2
  import { git, gitOk } from "./exec.js";
3
3
  export async function assertWorkTree(cwd) {
4
4
  const inside = await git(cwd, ["rev-parse", "--is-inside-work-tree"], { allowFail: true });
@@ -30,12 +30,18 @@ export function nameFromRemoteUrl(url) {
30
30
  const last = pathPart.split("/").filter(Boolean).at(-1);
31
31
  return last === undefined || last === "" ? null : last;
32
32
  }
33
+ export async function gitCommonDir(cwd) {
34
+ const raw = (await git(cwd, ["rev-parse", "--path-format=absolute", "--git-common-dir"])).trim();
35
+ return isAbsolute(raw) ? raw : resolve(cwd, raw);
36
+ }
33
37
  export async function readRepoIdentity(cwd) {
34
38
  const originText = (await git(cwd, ["config", "--get", "remote.origin.url"], { allowFail: true })).trim();
35
39
  const origin = originText === "" ? null : stripRemoteCredentials(originText);
36
- const top = (await git(cwd, ["rev-parse", "--show-toplevel"])).trim();
37
40
  const fromOrigin = origin !== null ? nameFromRemoteUrl(origin) : null;
38
- return { name: fromOrigin ?? basename(top), origin };
41
+ if (fromOrigin !== null) {
42
+ return { name: fromOrigin, origin };
43
+ }
44
+ return { name: basename(dirname(await gitCommonDir(cwd))), origin };
39
45
  }
40
46
  /** Drop userinfo from an http(s) remote so a copied prompt never carries a token. */
41
47
  export function stripRemoteCredentials(url) {
@@ -0,0 +1,30 @@
1
+ import { fileExistsAt, showFile } from "../git/show.js";
2
+ import { isLinePinned, textLineCount } from "../schema/source.js";
3
+ export async function staleCommentPins(cwd, document, range) {
4
+ const stale = [];
5
+ for (const source of document.sources ?? []) {
6
+ if (!isLinePinned(source)) {
7
+ continue;
8
+ }
9
+ if (!(await commentPinMatches(cwd, source, range))) {
10
+ stale.push({ id: source.id, path: source.path, side: source.side, line: source.line });
11
+ }
12
+ }
13
+ return stale;
14
+ }
15
+ export function commentPinErrors(pins) {
16
+ if (pins.length === 0) {
17
+ return [];
18
+ }
19
+ const lines = pins.map((pin) => ` ${pin.id} ${pin.path} ${pin.side}:${pin.line}`);
20
+ return [`stale: ${pins.length} comment pin(s) do not match live git:\n${lines.join("\n")}`];
21
+ }
22
+ async function commentPinMatches(cwd, source, range) {
23
+ const sha = source.side === "old" ? range.baseSha : range.headSha;
24
+ if (!(await fileExistsAt(cwd, sha, source.path))) {
25
+ return false;
26
+ }
27
+ const content = await showFile(cwd, sha, source.path);
28
+ const count = textLineCount(content);
29
+ return source.line >= 1 && source.line <= count;
30
+ }
@@ -1,8 +1,34 @@
1
- import { isReviewSize, REVIEW_SIZES } from "./types.js";
2
- const DOCUMENT_KEYS = new Set(["version", "source", "size", "summary", "why", "tickets", "groups"]);
1
+ import { isReviewSize, isSourceKind, isSourceSide, REVIEW_SIZES, SOURCE_KINDS, } from "./types.js";
2
+ const DOCUMENT_KEYS = new Set(["version", "source", "size", "title", "summary", "why", "tickets", "sources", "groups"]);
3
3
  const SOURCE_KEYS = new Set(["baseRef", "headRef", "range"]);
4
4
  const TICKET_KEYS = new Set(["id", "url", "title", "part"]);
5
- const GROUP_KEYS = new Set(["id", "title", "why", "summary", "lookFor", "dependsOn", "part", "suggestedOrder", "hunkRefs"]);
5
+ const REVIEW_SOURCE_KEYS = new Set([
6
+ "id",
7
+ "kind",
8
+ "label",
9
+ "url",
10
+ "title",
11
+ "gist",
12
+ "part",
13
+ "author",
14
+ "body",
15
+ "path",
16
+ "side",
17
+ "line",
18
+ ]);
19
+ const COMMENT_ONLY_KEYS = ["author", "body", "path", "side", "line"];
20
+ const GROUP_KEYS = new Set([
21
+ "id",
22
+ "title",
23
+ "why",
24
+ "summary",
25
+ "lookFor",
26
+ "dependsOn",
27
+ "part",
28
+ "sources",
29
+ "suggestedOrder",
30
+ "hunkRefs",
31
+ ]);
6
32
  const HUNK_KEYS = new Set(["path", "oldPath", "oldStart", "oldLines", "newStart", "newLines"]);
7
33
  export function parseReviewDocument(input) {
8
34
  const errors = [];
@@ -15,25 +41,27 @@ export function parseReviewDocument(input) {
15
41
  }
16
42
  const source = parseSource(input.source, errors);
17
43
  const size = parseSize(input.size, errors);
44
+ const title = requiredString(input.title, "title", errors);
18
45
  const summary = requiredString(input.summary, "summary", errors);
19
- const tickets = parseTickets(input.tickets, errors);
20
- const groups = parseGroups(input.groups, errors);
46
+ const sources = parseDocumentSources(input.sources, input.tickets, errors);
47
+ const groups = parseGroups(input.groups, sources, errors);
21
48
  const why = input.why === undefined ? undefined : requiredString(input.why, "why", errors);
22
- if (errors.length > 0 || source === undefined || size === undefined || summary === undefined) {
49
+ if (errors.length > 0 || source === undefined || size === undefined || title === undefined || summary === undefined) {
23
50
  return { ok: false, errors };
24
51
  }
25
52
  const document = {
26
53
  version: 1,
27
54
  source,
28
55
  size,
56
+ title,
29
57
  summary,
30
58
  groups,
31
59
  };
32
60
  if (why !== undefined) {
33
61
  document.why = why;
34
62
  }
35
- if (tickets !== undefined) {
36
- document.tickets = tickets;
63
+ if (sources !== undefined) {
64
+ document.sources = sources;
37
65
  }
38
66
  return { ok: true, document };
39
67
  }
@@ -75,15 +103,154 @@ function parseSource(value, errors) {
75
103
  }
76
104
  return source;
77
105
  }
78
- function parseTickets(value, errors) {
79
- if (value === undefined) {
106
+ function parseDocumentSources(sourcesValue, ticketsValue, errors) {
107
+ if (sourcesValue !== undefined && ticketsValue !== undefined) {
108
+ errors.push("document has both sources and tickets; use sources");
109
+ }
110
+ if (sourcesValue !== undefined) {
111
+ return parseSources(sourcesValue, errors);
112
+ }
113
+ if (ticketsValue !== undefined) {
114
+ return parseLegacyTickets(ticketsValue, errors);
115
+ }
116
+ return undefined;
117
+ }
118
+ function parseSources(value, errors) {
119
+ if (!Array.isArray(value)) {
120
+ errors.push("sources must be an array");
121
+ return undefined;
122
+ }
123
+ const sources = [];
124
+ const ids = new Set();
125
+ value.forEach((item, i) => {
126
+ const source = parseSourceItem(item, `sources[${i}]`, errors);
127
+ if (source === undefined) {
128
+ return;
129
+ }
130
+ if (ids.has(source.id)) {
131
+ errors.push(`duplicate source id "${source.id}"`);
132
+ }
133
+ ids.add(source.id);
134
+ sources.push(source);
135
+ });
136
+ return sources;
137
+ }
138
+ function parseSourceItem(value, label, errors) {
139
+ if (!isRecord(value)) {
140
+ errors.push(`${label} must be an object`);
141
+ return undefined;
142
+ }
143
+ extraKeys(value, REVIEW_SOURCE_KEYS, label, errors);
144
+ const id = requiredString(value.id, `${label}.id`, errors);
145
+ const kind = parseKind(value.kind, `${label}.kind`, errors);
146
+ const sourceLabel = requiredString(value.label, `${label}.label`, errors);
147
+ if (id === undefined || kind === undefined || sourceLabel === undefined) {
80
148
  return undefined;
81
149
  }
150
+ const source = { id, kind, label: sourceLabel };
151
+ if (value.url !== undefined) {
152
+ const url = requiredString(value.url, `${label}.url`, errors);
153
+ if (url !== undefined) {
154
+ source.url = url;
155
+ }
156
+ }
157
+ if (value.title !== undefined) {
158
+ const title = requiredString(value.title, `${label}.title`, errors);
159
+ if (title !== undefined) {
160
+ source.title = title;
161
+ }
162
+ }
163
+ if (value.gist !== undefined) {
164
+ const gist = requiredString(value.gist, `${label}.gist`, errors);
165
+ if (gist !== undefined) {
166
+ source.gist = gist;
167
+ }
168
+ }
169
+ if (value.part !== undefined) {
170
+ const part = requiredString(value.part, `${label}.part`, errors);
171
+ if (part !== undefined) {
172
+ source.part = part;
173
+ }
174
+ }
175
+ if (kind === "transcript" && source.url !== undefined) {
176
+ errors.push(`${label}.url must be omitted for transcripts`);
177
+ }
178
+ assignCommentFields(value, source, kind, label, errors);
179
+ return source;
180
+ }
181
+ function assignCommentFields(value, source, kind, label, errors) {
182
+ const present = COMMENT_ONLY_KEYS.filter((key) => value[key] !== undefined);
183
+ if (kind !== "pr-comment") {
184
+ for (const key of present) {
185
+ errors.push(`${label}.${key} is only valid on pr-comment sources`);
186
+ }
187
+ return;
188
+ }
189
+ if (value.author !== undefined) {
190
+ const author = requiredString(value.author, `${label}.author`, errors);
191
+ if (author !== undefined) {
192
+ source.author = author;
193
+ }
194
+ }
195
+ if (value.body !== undefined) {
196
+ const body = requiredString(value.body, `${label}.body`, errors, { allowEmpty: true });
197
+ if (body !== undefined) {
198
+ source.body = body;
199
+ }
200
+ }
201
+ if (source.author === undefined) {
202
+ errors.push(`${label}.author is required on pr-comment sources`);
203
+ }
204
+ if (source.body === undefined) {
205
+ errors.push(`${label}.body is required on pr-comment sources`);
206
+ }
207
+ const pinCount = [value.path, value.side, value.line].filter((item) => item !== undefined).length;
208
+ if (pinCount === 0) {
209
+ return;
210
+ }
211
+ if (pinCount !== 3) {
212
+ errors.push(`${label} line pin needs path, side, and line together`);
213
+ }
214
+ if (value.path !== undefined) {
215
+ const path = requiredString(value.path, `${label}.path`, errors);
216
+ if (path !== undefined) {
217
+ source.path = path;
218
+ }
219
+ }
220
+ if (value.side !== undefined) {
221
+ if (!isSourceSide(value.side)) {
222
+ errors.push(`${label}.side must be old or new`);
223
+ }
224
+ else {
225
+ source.side = value.side;
226
+ }
227
+ }
228
+ if (value.line !== undefined) {
229
+ const line = requiredInt(value.line, `${label}.line`, errors);
230
+ if (line !== undefined) {
231
+ if (line < 1) {
232
+ errors.push(`${label}.line must be a positive integer`);
233
+ }
234
+ else {
235
+ source.line = line;
236
+ }
237
+ }
238
+ }
239
+ }
240
+ function parseKind(value, label, errors) {
241
+ if (isSourceKind(value)) {
242
+ return value;
243
+ }
244
+ errors.push(`${label} must be one of ${SOURCE_KINDS.join(", ")}`);
245
+ return undefined;
246
+ }
247
+ function parseLegacyTickets(value, errors) {
82
248
  if (!Array.isArray(value)) {
83
249
  errors.push("tickets must be an array");
84
250
  return undefined;
85
251
  }
86
- const tickets = [];
252
+ const sources = [];
253
+ const ids = new Set();
87
254
  value.forEach((item, i) => {
88
255
  if (!isRecord(item)) {
89
256
  errors.push(`tickets[${i}] must be an object`);
@@ -94,30 +261,34 @@ function parseTickets(value, errors) {
94
261
  if (id === undefined) {
95
262
  return;
96
263
  }
97
- const ticket = { id };
264
+ if (ids.has(id)) {
265
+ errors.push(`duplicate source id "${id}"`);
266
+ }
267
+ ids.add(id);
268
+ const source = { id, kind: "ticket", label: id };
98
269
  if (item.url !== undefined) {
99
270
  const url = requiredString(item.url, `tickets[${i}].url`, errors);
100
271
  if (url !== undefined) {
101
- ticket.url = url;
272
+ source.url = url;
102
273
  }
103
274
  }
104
275
  if (item.title !== undefined) {
105
276
  const title = requiredString(item.title, `tickets[${i}].title`, errors);
106
277
  if (title !== undefined) {
107
- ticket.title = title;
278
+ source.title = title;
108
279
  }
109
280
  }
110
281
  if (item.part !== undefined) {
111
282
  const part = requiredString(item.part, `tickets[${i}].part`, errors);
112
283
  if (part !== undefined) {
113
- ticket.part = part;
284
+ source.part = part;
114
285
  }
115
286
  }
116
- tickets.push(ticket);
287
+ sources.push(source);
117
288
  });
118
- return tickets;
289
+ return sources;
119
290
  }
120
- function parseGroups(value, errors) {
291
+ function parseGroups(value, sources, errors) {
121
292
  if (!Array.isArray(value)) {
122
293
  errors.push("groups must be an array");
123
294
  return [];
@@ -138,6 +309,7 @@ function parseGroups(value, errors) {
138
309
  const hunkRefs = parseHunkRefs(item.hunkRefs, `groups[${i}].hunkRefs`, errors);
139
310
  const lookFor = parseStringList(item.lookFor, `groups[${i}].lookFor`, errors);
140
311
  const dependsOn = parseStringList(item.dependsOn, `groups[${i}].dependsOn`, errors);
312
+ const groupSources = parseStringList(item.sources, `groups[${i}].sources`, errors);
141
313
  const part = item.part === undefined ? undefined : requiredString(item.part, `groups[${i}].part`, errors);
142
314
  if (id === undefined || title === undefined || why === undefined || summary === undefined || suggestedOrder === undefined) {
143
315
  return;
@@ -156,18 +328,32 @@ function parseGroups(value, errors) {
156
328
  if (part !== undefined) {
157
329
  group.part = part;
158
330
  }
331
+ if (groupSources !== undefined) {
332
+ group.sources = groupSources;
333
+ }
159
334
  groups.push(group);
160
335
  });
161
- const known = new Set(groups.map((group) => group.id));
336
+ const knownGroups = new Set(groups.map((group) => group.id));
337
+ const knownSources = new Set((sources ?? []).map((source) => source.id));
162
338
  for (const group of groups) {
163
339
  for (const dep of group.dependsOn ?? []) {
164
340
  if (dep === group.id) {
165
341
  errors.push(`groups id "${group.id}" depends on itself`);
166
342
  }
167
- else if (!known.has(dep)) {
343
+ else if (!knownGroups.has(dep)) {
168
344
  errors.push(`groups id "${group.id}" dependsOn unknown group "${dep}"`);
169
345
  }
170
346
  }
347
+ const seen = new Set();
348
+ for (const sourceId of group.sources ?? []) {
349
+ if (seen.has(sourceId)) {
350
+ errors.push(`groups id "${group.id}" lists source "${sourceId}" twice`);
351
+ }
352
+ seen.add(sourceId);
353
+ if (!knownSources.has(sourceId)) {
354
+ errors.push(`groups id "${group.id}" sources unknown id "${sourceId}"`);
355
+ }
356
+ }
171
357
  }
172
358
  return groups;
173
359
  }
@@ -4,6 +4,7 @@ import { dirname, join, relative } from "node:path";
4
4
  import { applyCliPin, cliPinErrors, listedCliPins } from "./cli-pin.js";
5
5
  import { skillPaths } from "./skill-paths.js";
6
6
  import { findPackageRoot } from "../package-root.js";
7
+ import { gitEnv } from "../git/exec.js";
7
8
  export function skillSyncErrors(input) {
8
9
  const errors = [];
9
10
  if (input.nextSchema !== input.canonicalSchema) {
@@ -110,6 +111,7 @@ function gitStagedTree(root, prefix) {
110
111
  const listing = execFileSync("git", ["ls-files", "-z", "--", prefix], {
111
112
  cwd: root,
112
113
  encoding: "utf8",
114
+ env: gitEnv(),
113
115
  });
114
116
  const out = new Map();
115
117
  for (const full of listing.split("\0").filter(Boolean)) {
@@ -119,7 +121,7 @@ function gitStagedTree(root, prefix) {
119
121
  }
120
122
  function gitShowStaged(root, path) {
121
123
  try {
122
- return execFileSync("git", ["show", `:${path}`], { cwd: root, encoding: "utf8" });
124
+ return execFileSync("git", ["show", `:${path}`], { cwd: root, encoding: "utf8", env: gitEnv() });
123
125
  }
124
126
  catch {
125
127
  throw new Error(`staged file missing: ${path}`);