comprehende 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -25,18 +25,25 @@ pnpm typecheck
25
25
  pnpm build
26
26
  pnpm pack:smoke
27
27
  pnpm sync:skill
28
+ pnpm release:skill
28
29
  pnpm dev -- --help
29
30
  ```
30
31
 
31
32
  `pnpm build` emits `dist/cli` and `dist/ui`. `prepack` runs that build, so `pnpm pack` / `pnpm publish` always ship the UI.
32
33
 
34
+ `pnpm sync:skill` copies the JSON Schema into `skills-next/comprehende/`, pins `npx comprehende@<version>` there, and mirrors that tree into `.agents/skills/comprehende` so agents in this checkout use the next skill. It does not touch `skills/comprehende/`.
35
+
33
36
  `comprehende serve` and `comprehende export` share one UI and one git payload layer. Serve computes those payloads on each request. Export writes the same JSON (and image bytes) next to the UI so any static file server can host the review.
34
37
 
35
- `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.3.0` (or `node /path/to/comprehende/dist/cli/main.js` after `pnpm build`).
38
+ `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.4.0` (or `node /path/to/comprehende/dist/cli/main.js` after `pnpm build`).
36
39
 
37
40
  ## Release
38
41
 
39
- Bump `version` in `package.json` when the CLI or UI changes, then run `pnpm sync:skill` so the skill pin matches. Pre-commit and `pnpm test` fail if the staged package version and skill pin differ. Do not bump for skill-only edits.
42
+ Edit the skill in `skills-next/comprehende/`. `npx skills add` reads `skills/comprehende/` only.
43
+
44
+ Bump `version` in `package.json` when the CLI or UI changes, then run `pnpm sync:skill` so the next skill pin matches. Pre-commit and `pnpm test` fail if the staged package version and next skill pin differ. Do not bump for skill-only edits.
45
+
46
+ When that next skill should ship with `npx skills add`, run `pnpm release:skill`. That copies `skills-next/comprehende/` onto `skills/comprehende/`. Run it in the same change that publishes a new CLI. Then `npx skills add` installs instructions that match the package they pin.
40
47
 
41
48
  Push to `main`. CI packs and tests the tarball on every change. If the version is not on npm yet, CI publishes it. Skill-only commits keep the same version, so they do not publish.
42
49
 
package/dist/api/live.js CHANGED
@@ -1,12 +1,13 @@
1
1
  import { loadDocument } from "../cli/commands.js";
2
2
  import { blameFile } from "../git/blame.js";
3
3
  import { readImageBlob } from "../git/blob.js";
4
- import { fileLanguage, filePatchFromGit, resolveSource, toHunkRef } from "../git/diff.js";
4
+ import { fileLanguage, filePatchFromGit, readPathDiff, resolveSource, toHunkRef } from "../git/diff.js";
5
5
  import { GitError } from "../git/exec.js";
6
6
  import { listCommits } from "../git/log.js";
7
7
  import { mergeBase } from "../git/repo.js";
8
8
  import { showFile } from "../git/show.js";
9
9
  import { coverReview } from "../review/coverage.js";
10
+ import { isLockfilePath } from "../schema/lockfile.js";
10
11
  import { ApiError } from "./error.js";
11
12
  export async function openReview(cwd, dataPath) {
12
13
  const document = await loadDocument(dataPath);
@@ -34,6 +35,7 @@ export async function openReview(cwd, dataPath) {
34
35
  }
35
36
  export function reviewPayload(ctx) {
36
37
  const { document, resolved, files, coverage, commits } = ctx;
38
+ const lockfiles = lockfileFiles(files);
37
39
  return {
38
40
  document,
39
41
  resolved,
@@ -49,6 +51,7 @@ export function reviewPayload(ctx) {
49
51
  .map((group) => ({
50
52
  id: group.group.id,
51
53
  title: group.group.title,
54
+ why: group.group.why,
52
55
  summary: group.group.summary,
53
56
  lookFor: group.group.lookFor ?? [],
54
57
  dependsOn: group.group.dependsOn ?? [],
@@ -62,6 +65,10 @@ export function reviewPayload(ctx) {
62
65
  hunkCount: coverage.unassigned.length,
63
66
  files: uniquePaths(coverage.unassigned),
64
67
  },
68
+ lockfiles: {
69
+ fileCount: lockfiles.length,
70
+ files: lockfiles.map((file) => file.path),
71
+ },
65
72
  stale: coverage.stale,
66
73
  files: files.map((file) => {
67
74
  const entry = {
@@ -87,6 +94,9 @@ export function hunksPayload(ctx, groupId) {
87
94
  if (groupId === "unassigned") {
88
95
  return serializeLayer(ctx.files, ctx.coverage.unassigned);
89
96
  }
97
+ if (groupId === "lockfiles") {
98
+ return serializeLockfiles(ctx.files);
99
+ }
90
100
  const group = ctx.coverage.groups.find((item) => item.group.id === groupId);
91
101
  if (group === undefined) {
92
102
  throw new ApiError(404, `unknown group "${groupId}"`);
@@ -153,10 +163,16 @@ export async function renderResource(ctx, resource) {
153
163
  return { encoding: "json", body: await blamePayload(ctx, resource.path, resource.side) };
154
164
  case "image":
155
165
  return imagePayload(ctx, resource.path, resource.side);
166
+ case "patch":
167
+ return { encoding: "json", body: await patchPayload(ctx, resource.path) };
156
168
  }
157
169
  }
158
170
  export function listResources(ctx) {
159
- const resources = [{ kind: "review" }, { kind: "hunks", group: "unassigned" }];
171
+ const resources = [
172
+ { kind: "review" },
173
+ { kind: "hunks", group: "unassigned" },
174
+ { kind: "hunks", group: "lockfiles" },
175
+ ];
160
176
  for (const group of ctx.document.groups) {
161
177
  resources.push({ kind: "hunks", group: group.id });
162
178
  }
@@ -167,6 +183,9 @@ export function listResources(ctx) {
167
183
  }
168
184
  continue;
169
185
  }
186
+ if (isLockfilePath(file.path) && !file.binary) {
187
+ resources.push({ kind: "patch", path: file.path });
188
+ }
170
189
  for (const side of sidesFor(file)) {
171
190
  resources.push({ kind: "file", path: file.path, side });
172
191
  resources.push({ kind: "blame", path: file.path, side });
@@ -197,21 +216,48 @@ function serializeLayer(files, hunks) {
197
216
  }
198
217
  groups.push({ file, hunks: [hunk] });
199
218
  }
200
- const serializedFiles = groups.map(({ file, hunks: fileHunks }) => {
201
- const next = {
202
- path: file.path,
203
- kind: file.image ? "image" : "text",
204
- status: file.status,
205
- patch: file.image ? file.headerPatch : filePatchFromGit(file, fileHunks),
206
- hunks: fileHunks.map(serializeHunk),
207
- };
208
- if (file.oldPath !== undefined) {
209
- next.oldPath = file.oldPath;
210
- }
211
- return next;
212
- });
219
+ const serializedFiles = groups.map(({ file, hunks: fileHunks }) => serializeLayerFile(file, fileHunks, true));
213
220
  return { hunks: hunks.map(serializeHunk), files: serializedFiles };
214
221
  }
222
+ function serializeLockfiles(files) {
223
+ const serializedFiles = lockfileFiles(files).map((file) => serializeLayerFile(file, [], true));
224
+ return { hunks: [], files: serializedFiles };
225
+ }
226
+ function lockfileFiles(files) {
227
+ return files.filter((file) => isLockfilePath(file.path) && !file.binary && !file.image);
228
+ }
229
+ async function patchPayload(ctx, path) {
230
+ const file = findFile(ctx.files, path);
231
+ if (!isLockfilePath(file.path) || file.binary || file.image) {
232
+ throw new ApiError(404, "path is not a deferred lockfile");
233
+ }
234
+ const live = await readPathDiff(ctx.cwd, ctx.document.source.baseRef, ctx.document.source.headRef, file.path);
235
+ if (live === undefined) {
236
+ throw new ApiError(404, `no live diff for ${path}`);
237
+ }
238
+ return serializeLayerFile(live, live.hunks, false);
239
+ }
240
+ function serializeLayerFile(file, fileHunks, deferLockfile) {
241
+ const lockfile = isLockfilePath(file.path) && !file.binary && !file.image;
242
+ const deferred = deferLockfile && lockfile;
243
+ const next = {
244
+ path: file.path,
245
+ kind: file.image ? "image" : lockfile ? "lockfile" : "text",
246
+ status: file.status,
247
+ patch: deferred ? "" : file.image ? file.headerPatch : filePatchFromGit(file, fileHunks),
248
+ hunks: fileHunks.map(serializeHunk),
249
+ };
250
+ if (file.oldPath !== undefined) {
251
+ next.oldPath = file.oldPath;
252
+ }
253
+ if (file.added !== undefined) {
254
+ next.added = file.added;
255
+ }
256
+ if (file.removed !== undefined) {
257
+ next.removed = file.removed;
258
+ }
259
+ return next;
260
+ }
215
261
  function serializeHunk(hunk) {
216
262
  return {
217
263
  ...toHunkRef(hunk),
package/dist/api/paths.js CHANGED
@@ -10,6 +10,8 @@ export function apiHref(resource) {
10
10
  return `api/blame/${resource.side}/${encodeFilePath(resource.path)}.json`;
11
11
  case "image":
12
12
  return `api/images/${resource.side}/${encodeFilePath(resource.path)}`;
13
+ case "patch":
14
+ return `api/patches/${encodeFilePath(resource.path)}.json`;
13
15
  }
14
16
  }
15
17
  /** Decoded relative path under the site root. Static hosts map encoded request URLs onto this. */
@@ -25,6 +27,8 @@ export function apiFsRel(resource) {
25
27
  return `api/blame/${resource.side}/${resource.path}.json`;
26
28
  case "image":
27
29
  return `api/images/${resource.side}/${resource.path}`;
30
+ case "patch":
31
+ return `api/patches/${resource.path}.json`;
28
32
  }
29
33
  }
30
34
  export function parseApiPath(pathname) {
@@ -53,6 +57,20 @@ export function parseApiPath(pathname) {
53
57
  }
54
58
  return { kind: "image", path, side };
55
59
  }
60
+ if (parts[1] === "patches" && parts.length >= 3) {
61
+ const rest = parts.slice(2);
62
+ const last = rest.at(-1);
63
+ const stem = last === undefined ? undefined : jsonStem(last);
64
+ if (stem === undefined) {
65
+ return undefined;
66
+ }
67
+ rest[rest.length - 1] = stem;
68
+ const path = rest.join("/");
69
+ if (!isRepoPath(path)) {
70
+ return undefined;
71
+ }
72
+ return { kind: "patch", path };
73
+ }
56
74
  if ((parts[1] === "files" || parts[1] === "blame") && parts.length >= 4 && parts[2] !== undefined) {
57
75
  const side = parts[2];
58
76
  if (side !== "old" && side !== "new") {
package/dist/git/diff.js CHANGED
@@ -1,6 +1,8 @@
1
1
  import { isImagePath, isLfsPointerText } from "../schema/image.js";
2
+ import { isLockfilePath } from "../schema/lockfile.js";
2
3
  import { git } from "./exec.js";
3
- import { rangeLabel, resolveCommit } from "./repo.js";
4
+ import { parseNameStatus, parseNumstat } from "./name-status.js";
5
+ import { assertSafePath, rangeLabel, resolveCommit } from "./repo.js";
4
6
  const HUNK_HEADER = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/;
5
7
  export async function resolveSource(cwd, baseRef, headRef) {
6
8
  const baseSha = await resolveCommit(cwd, baseRef);
@@ -12,6 +14,53 @@ export async function resolveSource(cwd, baseRef, headRef) {
12
14
  };
13
15
  }
14
16
  export async function readDiff(cwd, baseRef, headRef) {
17
+ await resolveCommit(cwd, baseRef);
18
+ await resolveCommit(cwd, headRef);
19
+ const range = `${baseRef}...${headRef}`;
20
+ const entries = parseNameStatus(await git(cwd, ["diff", "--find-renames", "--find-copies", "--name-status", "-z", "--end-of-options", range]));
21
+ const lockEntries = entries.filter((entry) => isLockfilePath(entry.path));
22
+ const diffArgs = [
23
+ "diff",
24
+ "--find-renames",
25
+ "--find-copies",
26
+ "-U3",
27
+ "--no-color",
28
+ "--no-ext-diff",
29
+ "--end-of-options",
30
+ range,
31
+ ];
32
+ if (lockEntries.length > 0) {
33
+ const excludes = [
34
+ ...new Set(lockEntries.flatMap((entry) => {
35
+ const paths = [`:(exclude)${entry.path}`];
36
+ if (entry.oldPath !== undefined) {
37
+ paths.push(`:(exclude)${entry.oldPath}`);
38
+ }
39
+ return paths;
40
+ })),
41
+ ];
42
+ diffArgs.push("--", ".", ...excludes);
43
+ }
44
+ const files = classifyDiffFiles(parseUnifiedDiff(await git(cwd, diffArgs))).filter((file) => !isLockfilePath(file.path));
45
+ if (lockEntries.length === 0) {
46
+ return files;
47
+ }
48
+ const stats = parseNumstat(await git(cwd, [
49
+ "diff",
50
+ "--find-renames",
51
+ "--find-copies",
52
+ "--numstat",
53
+ "-z",
54
+ "--end-of-options",
55
+ range,
56
+ "--",
57
+ ...lockEntries.map((entry) => entry.path),
58
+ ]));
59
+ const stubs = lockEntries.map((entry) => lockfileDiffFile(entry, stats.get(entry.path)));
60
+ return mergeDiffFiles(entries, files, stubs);
61
+ }
62
+ export async function readPathDiff(cwd, baseRef, headRef, path) {
63
+ assertSafePath(path);
15
64
  await resolveCommit(cwd, baseRef);
16
65
  await resolveCommit(cwd, headRef);
17
66
  const stdout = await git(cwd, [
@@ -23,8 +72,11 @@ export async function readDiff(cwd, baseRef, headRef) {
23
72
  "--no-ext-diff",
24
73
  "--end-of-options",
25
74
  `${baseRef}...${headRef}`,
75
+ "--",
76
+ path,
26
77
  ]);
27
- return classifyDiffFiles(parseUnifiedDiff(stdout));
78
+ const files = classifyDiffFiles(parseUnifiedDiff(stdout));
79
+ return files.find((file) => file.path === path || file.oldPath === path);
28
80
  }
29
81
  export async function readHunkIndex(cwd, baseRef, headRef) {
30
82
  const { source } = await resolveSource(cwd, baseRef, headRef);
@@ -42,6 +94,10 @@ export async function readHunkIndex(cwd, baseRef, headRef) {
42
94
  skipped.push({ path: file.path, reason: "binary" });
43
95
  continue;
44
96
  }
97
+ if (isLockfilePath(file.path)) {
98
+ skipped.push({ path: file.path, reason: "lockfile" });
99
+ continue;
100
+ }
45
101
  for (const hunk of file.hunks) {
46
102
  hunks.push(toHunkRef(hunk));
47
103
  }
@@ -293,6 +349,9 @@ export function flattenHunks(files) {
293
349
  if (file.binary && !file.image) {
294
350
  continue;
295
351
  }
352
+ if (isLockfilePath(file.path)) {
353
+ continue;
354
+ }
296
355
  hunks.push(...file.hunks);
297
356
  }
298
357
  return hunks;
@@ -328,6 +387,46 @@ export function imageLiveHunk(path, oldPath) {
328
387
  }
329
388
  return hunk;
330
389
  }
390
+ function lockfileDiffFile(entry, stat) {
391
+ const binary = stat === undefined || stat.added === null || stat.removed === null;
392
+ const file = {
393
+ path: entry.path,
394
+ status: entry.status,
395
+ binary,
396
+ image: false,
397
+ headerPatch: "",
398
+ patch: "",
399
+ hunks: [],
400
+ };
401
+ if (entry.oldPath !== undefined) {
402
+ file.oldPath = entry.oldPath;
403
+ }
404
+ if (!binary && stat !== undefined && stat.added !== null && stat.removed !== null) {
405
+ file.added = stat.added;
406
+ file.removed = stat.removed;
407
+ }
408
+ return file;
409
+ }
410
+ function mergeDiffFiles(entries, files, stubs) {
411
+ const filesByPath = new Map(files.map((file) => [file.path, file]));
412
+ const stubsByPath = new Map(stubs.map((file) => [file.path, file]));
413
+ const out = [];
414
+ const seen = new Set();
415
+ for (const entry of entries) {
416
+ const file = stubsByPath.get(entry.path) ?? filesByPath.get(entry.path);
417
+ if (file === undefined || seen.has(file.path)) {
418
+ continue;
419
+ }
420
+ seen.add(file.path);
421
+ out.push(file);
422
+ }
423
+ for (const file of files) {
424
+ if (!seen.has(file.path)) {
425
+ out.push(file);
426
+ }
427
+ }
428
+ return out;
429
+ }
331
430
  export function fileLanguage(path) {
332
431
  if (isImagePath(path)) {
333
432
  return "image";
package/dist/git/log.js CHANGED
@@ -1,8 +1,10 @@
1
1
  import { git } from "./exec.js";
2
+ const RECORD_SEP = "\x1e";
3
+ const FIELD_SEP = "\0";
2
4
  export async function listCommits(cwd, baseRef, headRef) {
3
5
  const stdout = await git(cwd, [
4
6
  "log",
5
- "--format=%H%x00%h%x00%s%x00%an%x00%ad",
7
+ "--format=%H%x00%h%x00%s%x00%an%x00%ad%x00%b%x1e",
6
8
  "--date=short",
7
9
  "--end-of-options",
8
10
  `${baseRef}...${headRef}`,
@@ -11,16 +13,19 @@ export async function listCommits(cwd, baseRef, headRef) {
11
13
  return [];
12
14
  }
13
15
  return stdout
14
- .split("\n")
15
- .filter((line) => line.length > 0)
16
- .map((line) => {
17
- const [sha, shortSha, subject, author, date] = line.split("\0");
18
- return {
19
- sha: sha ?? "",
20
- shortSha: shortSha ?? "",
21
- subject: subject ?? "",
22
- author: author ?? "",
23
- date: date ?? "",
24
- };
25
- });
16
+ .split(RECORD_SEP)
17
+ .map((record) => record.replace(/^\n/, ""))
18
+ .filter((record) => record.length > 0)
19
+ .map(parseCommitRecord);
20
+ }
21
+ function parseCommitRecord(record) {
22
+ const [sha, shortSha, subject, author, date, ...bodyParts] = record.split(FIELD_SEP);
23
+ return {
24
+ sha: sha ?? "",
25
+ shortSha: shortSha ?? "",
26
+ subject: subject ?? "",
27
+ author: author ?? "",
28
+ date: date ?? "",
29
+ body: (bodyParts.join(FIELD_SEP) ?? "").replace(/\n+$/u, ""),
30
+ };
26
31
  }
@@ -0,0 +1,73 @@
1
+ export function parseNameStatus(stdout) {
2
+ const parts = stdout.split("\0").filter((part) => part !== "");
3
+ const entries = [];
4
+ let index = 0;
5
+ while (index < parts.length) {
6
+ const code = parts[index];
7
+ index += 1;
8
+ if (code === undefined) {
9
+ break;
10
+ }
11
+ if (code.startsWith("R") || code.startsWith("C")) {
12
+ const oldPath = parts[index];
13
+ const path = parts[index + 1];
14
+ index += 2;
15
+ if (oldPath === undefined || path === undefined) {
16
+ break;
17
+ }
18
+ entries.push({ status: "renamed", path, oldPath });
19
+ continue;
20
+ }
21
+ const path = parts[index];
22
+ index += 1;
23
+ if (path === undefined) {
24
+ break;
25
+ }
26
+ entries.push({ status: statusFrom(code), path });
27
+ }
28
+ return entries;
29
+ }
30
+ /** Git `--numstat -z`: `added\\tdeleted\\tpath\\0`, or `added\\tdeleted\\t\\0old\\0new\\0` for a rename. */
31
+ export function parseNumstat(stdout) {
32
+ const parts = stdout.split("\0");
33
+ const out = new Map();
34
+ let index = 0;
35
+ while (index < parts.length) {
36
+ const field = parts[index];
37
+ if (field === undefined || field === "") {
38
+ index += 1;
39
+ continue;
40
+ }
41
+ const match = /^(-|\d+)\t(-|\d+)\t(.*)$/.exec(field);
42
+ if (match === null || match[1] === undefined || match[2] === undefined || match[3] === undefined) {
43
+ break;
44
+ }
45
+ index += 1;
46
+ const added = parseCount(match[1]);
47
+ const removed = parseCount(match[2]);
48
+ if (match[3] !== "") {
49
+ out.set(match[3], { path: match[3], added, removed });
50
+ continue;
51
+ }
52
+ const oldPath = parts[index];
53
+ const path = parts[index + 1];
54
+ index += 2;
55
+ if (oldPath === undefined || path === undefined || oldPath === "" || path === "") {
56
+ break;
57
+ }
58
+ out.set(path, { path, oldPath, added, removed });
59
+ }
60
+ return out;
61
+ }
62
+ function parseCount(value) {
63
+ return value === "-" ? null : Number(value);
64
+ }
65
+ function statusFrom(code) {
66
+ if (code.startsWith("A")) {
67
+ return "added";
68
+ }
69
+ if (code.startsWith("D")) {
70
+ return "deleted";
71
+ }
72
+ return "modified";
73
+ }
@@ -1,5 +1,6 @@
1
1
  import { flattenHunks, readDiff, toHunkRef } from "../git/diff.js";
2
2
  import { hunkKey } from "../schema/identity.js";
3
+ import { isLockfilePath } from "../schema/lockfile.js";
3
4
  export async function coverReview(cwd, document) {
4
5
  const files = await readDiff(cwd, document.source.baseRef, document.source.headRef);
5
6
  const live = flattenHunks(files);
@@ -16,6 +17,9 @@ export function joinCoverage(document, live) {
16
17
  const hunks = [];
17
18
  const stale = [];
18
19
  for (const ref of group.hunkRefs) {
20
+ if (isLockfilePath(ref.path)) {
21
+ continue;
22
+ }
19
23
  const match = liveByKey.get(hunkKey(ref));
20
24
  if (match === undefined) {
21
25
  stale.push(ref);
@@ -23,7 +27,7 @@ export function joinCoverage(document, live) {
23
27
  continue;
24
28
  }
25
29
  hunks.push(match);
26
- assignedKeys.add(hunkKey(ref));
30
+ assignedKeys.add(hunkKey(match));
27
31
  }
28
32
  return { group, hunks, stale };
29
33
  });
@@ -0,0 +1,38 @@
1
+ const LOCKFILE_NAMES = new Set([
2
+ ".terraform.lock.hcl",
3
+ "bun.lock",
4
+ "bun.lockb",
5
+ "cabal.project.freeze",
6
+ "Cargo.lock",
7
+ "Cartfile.resolved",
8
+ "composer.lock",
9
+ "conda-lock.yml",
10
+ "deno.lock",
11
+ "flake.lock",
12
+ "Gemfile.lock",
13
+ "go.sum",
14
+ "go.work.sum",
15
+ "Gopkg.lock",
16
+ "gradle.lockfile",
17
+ "lazy-lock.json",
18
+ "mix.lock",
19
+ "npm-shrinkwrap.json",
20
+ "package-lock.json",
21
+ "Package.resolved",
22
+ "pdm.lock",
23
+ "Pipfile.lock",
24
+ "pixi.lock",
25
+ "pnpm-lock.yaml",
26
+ "Podfile.lock",
27
+ "poetry.lock",
28
+ "pubspec.lock",
29
+ "renv.lock",
30
+ "shrinkwrap.yaml",
31
+ "stack.yaml.lock",
32
+ "uv.lock",
33
+ "yarn.lock",
34
+ ]);
35
+ export function isLockfilePath(path) {
36
+ const base = path.split("/").pop() ?? path;
37
+ return LOCKFILE_NAMES.has(base) || base.endsWith(".gradle.lockfile");
38
+ }
@@ -1,8 +1,8 @@
1
1
  import { isReviewSize, REVIEW_SIZES } from "./types.js";
2
- const DOCUMENT_KEYS = new Set(["version", "source", "size", "walkthrough", "tickets", "groups"]);
2
+ const DOCUMENT_KEYS = new Set(["version", "source", "size", "summary", "why", "tickets", "groups"]);
3
3
  const SOURCE_KEYS = new Set(["baseRef", "headRef", "range"]);
4
- const TICKET_KEYS = new Set(["id", "url", "title"]);
5
- const GROUP_KEYS = new Set(["id", "title", "summary", "lookFor", "dependsOn", "part", "suggestedOrder", "hunkRefs"]);
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"]);
6
6
  const HUNK_KEYS = new Set(["path", "oldPath", "oldStart", "oldLines", "newStart", "newLines"]);
7
7
  export function parseReviewDocument(input) {
8
8
  const errors = [];
@@ -15,20 +15,22 @@ export function parseReviewDocument(input) {
15
15
  }
16
16
  const source = parseSource(input.source, errors);
17
17
  const size = parseSize(input.size, errors);
18
+ const summary = requiredString(input.summary, "summary", errors);
18
19
  const tickets = parseTickets(input.tickets, errors);
19
20
  const groups = parseGroups(input.groups, errors);
20
- const walkthrough = input.walkthrough === undefined ? undefined : requiredString(input.walkthrough, "walkthrough", errors);
21
- if (errors.length > 0 || source === undefined || size === undefined) {
21
+ const why = input.why === undefined ? undefined : requiredString(input.why, "why", errors);
22
+ if (errors.length > 0 || source === undefined || size === undefined || summary === undefined) {
22
23
  return { ok: false, errors };
23
24
  }
24
25
  const document = {
25
26
  version: 1,
26
27
  source,
27
28
  size,
29
+ summary,
28
30
  groups,
29
31
  };
30
- if (walkthrough !== undefined) {
31
- document.walkthrough = walkthrough;
32
+ if (why !== undefined) {
33
+ document.why = why;
32
34
  }
33
35
  if (tickets !== undefined) {
34
36
  document.tickets = tickets;
@@ -105,6 +107,12 @@ function parseTickets(value, errors) {
105
107
  ticket.title = title;
106
108
  }
107
109
  }
110
+ if (item.part !== undefined) {
111
+ const part = requiredString(item.part, `tickets[${i}].part`, errors);
112
+ if (part !== undefined) {
113
+ ticket.part = part;
114
+ }
115
+ }
108
116
  tickets.push(ticket);
109
117
  });
110
118
  return tickets;
@@ -124,20 +132,21 @@ function parseGroups(value, errors) {
124
132
  extraKeys(item, GROUP_KEYS, `groups[${i}]`, errors);
125
133
  const id = requiredString(item.id, `groups[${i}].id`, errors);
126
134
  const title = requiredString(item.title, `groups[${i}].title`, errors);
135
+ const why = requiredString(item.why, `groups[${i}].why`, errors);
127
136
  const summary = requiredString(item.summary, `groups[${i}].summary`, errors, { allowEmpty: true });
128
137
  const suggestedOrder = requiredNumber(item.suggestedOrder, `groups[${i}].suggestedOrder`, errors);
129
138
  const hunkRefs = parseHunkRefs(item.hunkRefs, `groups[${i}].hunkRefs`, errors);
130
139
  const lookFor = parseStringList(item.lookFor, `groups[${i}].lookFor`, errors);
131
140
  const dependsOn = parseStringList(item.dependsOn, `groups[${i}].dependsOn`, errors);
132
141
  const part = item.part === undefined ? undefined : requiredString(item.part, `groups[${i}].part`, errors);
133
- if (id === undefined || title === undefined || summary === undefined || suggestedOrder === undefined) {
142
+ if (id === undefined || title === undefined || why === undefined || summary === undefined || suggestedOrder === undefined) {
134
143
  return;
135
144
  }
136
145
  if (ids.has(id)) {
137
146
  errors.push(`duplicate group id "${id}"`);
138
147
  }
139
148
  ids.add(id);
140
- const group = { id, title, summary, suggestedOrder, hunkRefs };
149
+ const group = { id, title, why, summary, suggestedOrder, hunkRefs };
141
150
  if (lookFor !== undefined) {
142
151
  group.lookFor = lookFor;
143
152
  }
@@ -3,9 +3,9 @@ import { findPackageRoot } from "../package-root.js";
3
3
  export function skillPaths(root = findPackageRoot()) {
4
4
  return {
5
5
  canonicalSchema: join(root, "src/schema/review.schema.json"),
6
+ nextSkill: join(root, "skills-next/comprehende"),
6
7
  publishedSkill: join(root, "skills/comprehende"),
7
8
  installedSkill: join(root, ".agents/skills/comprehende"),
8
- publishedSchema: join(root, "skills/comprehende/references/review.schema.json"),
9
- installedSchema: join(root, ".agents/skills/comprehende/references/review.schema.json"),
9
+ nextSchema: join(root, "skills-next/comprehende/references/review.schema.json"),
10
10
  };
11
11
  }