@letta-ai/letta-code 0.30.32 → 0.31.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 (61) hide show
  1. package/dist/agent-presets-agent-presets.js +11 -0
  2. package/dist/agent-presets-agent-presets.js.map +9 -0
  3. package/dist/agent-presets-personality-asset-content.js +42 -0
  4. package/dist/agent-presets-personality-asset-content.js.map +10 -0
  5. package/dist/agent-presets.js +17 -2
  6. package/dist/agent-presets.js.map +3 -3
  7. package/dist/channels-public.js +16 -1
  8. package/dist/channels-public.js.map +5 -4
  9. package/dist/gateway-core.js +5 -4
  10. package/dist/gateway-core.js.map +3 -3
  11. package/dist/mcp-client.js +2 -2
  12. package/dist/mcp-client.js.map +1 -1
  13. package/dist/types/agent/create-agent-request.d.ts +3 -0
  14. package/dist/types/agent/create-agent-request.d.ts.map +1 -1
  15. package/dist/types/agent/model.d.ts.map +1 -1
  16. package/dist/types/agent/personality-asset-content.d.ts +3 -0
  17. package/dist/types/agent/personality-asset-content.d.ts.map +1 -0
  18. package/dist/types/agent/skills.d.ts.map +1 -1
  19. package/dist/types/channels/gateway-core.d.ts.map +1 -1
  20. package/dist/types/channels/route-thread-key.d.ts +20 -0
  21. package/dist/types/channels/route-thread-key.d.ts.map +1 -0
  22. package/dist/types/channels-public.d.ts +1 -0
  23. package/dist/types/channels-public.d.ts.map +1 -1
  24. package/dist/types/tools/impl/skill.d.ts.map +1 -1
  25. package/dist/types/types/protocol_v2.d.ts +3 -144
  26. package/dist/types/types/protocol_v2.d.ts.map +1 -1
  27. package/dist/types/types/schedule-protocol.d.ts +151 -1
  28. package/dist/types/types/schedule-protocol.d.ts.map +1 -1
  29. package/dist/types/utils/frontmatter.d.ts.map +1 -1
  30. package/dist/types/websocket/listener/cwd-change.d.ts.map +1 -1
  31. package/letta.js +238 -86
  32. package/package.json +4 -2
  33. package/scripts/builtin-skills-watch/agent-watch.test.ts +104 -0
  34. package/scripts/builtin-skills-watch/agent-watch.ts +337 -0
  35. package/scripts/builtin-skills-watch/aggregate-results.test.ts +79 -0
  36. package/scripts/builtin-skills-watch/aggregate-results.ts +326 -0
  37. package/scripts/builtin-skills-watch/analysis.test.ts +70 -0
  38. package/scripts/builtin-skills-watch/analysis.ts +228 -0
  39. package/scripts/builtin-skills-watch/evidence.test.ts +114 -0
  40. package/scripts/builtin-skills-watch/evidence.ts +145 -0
  41. package/scripts/builtin-skills-watch/github.ts +161 -0
  42. package/scripts/builtin-skills-watch/tracker.test.ts +249 -0
  43. package/scripts/builtin-skills-watch/tracker.ts +506 -0
  44. package/scripts/builtin-skills-watch/update-tracker.test.ts +234 -0
  45. package/scripts/builtin-skills-watch/update-tracker.ts +508 -0
  46. package/scripts/claude-watch/release-source.test.ts +16 -4
  47. package/scripts/claude-watch/release-source.ts +41 -2
  48. package/scripts/codex-watch/agent-watch.ts +8 -5
  49. package/scripts/codex-watch/release-analysis.test.ts +18 -16
  50. package/scripts/codex-watch/release-analysis.ts +31 -9
  51. package/scripts/codex-watch/tracker.test.ts +5 -5
  52. package/scripts/codex-watch/tracker.ts +7 -8
  53. package/scripts/pi-ai-watch/agent-watch.ts +9 -14
  54. package/scripts/pi-ai-watch/release-analysis.test.ts +24 -18
  55. package/scripts/pi-ai-watch/release-analysis.ts +85 -45
  56. package/scripts/pi-ai-watch/tracker.test.ts +25 -18
  57. package/scripts/pi-ai-watch/tracker.ts +7 -19
  58. package/scripts/pi-ai-watch/update-tracker.ts +2 -2
  59. package/scripts/source-file-size-baseline.json +2 -2
  60. package/skills/dispatching-coding-agents/SKILL.md +16 -7
  61. package/skills/syncing-memory-filesystem/SKILL.md +118 -226
@@ -0,0 +1,508 @@
1
+ #!/usr/bin/env bun
2
+ /** Records or verifies one built-in skill review outcome. */
3
+
4
+ import { readFileSync } from "node:fs";
5
+ import {
6
+ type BuiltinSkillWatchAnalysis,
7
+ buildAnalysis,
8
+ DEFAULT_TARGET_REPO,
9
+ } from "./analysis.ts";
10
+ import { parseReviewEvidence, type ReviewEvidence } from "./evidence.ts";
11
+ import { createIssueComment, editIssueBody, ghJson } from "./github.ts";
12
+ import {
13
+ hasTerminalCandidate,
14
+ parseTrackerState,
15
+ recordOutcome,
16
+ renderTrackerBody,
17
+ type TrackerOutcome,
18
+ } from "./tracker.ts";
19
+
20
+ interface Args {
21
+ repo: string;
22
+ trackerIssue: number | null;
23
+ analysisFile: string | null;
24
+ resultFile: string | null;
25
+ candidateId: string | null;
26
+ outcome: TrackerOutcome | null;
27
+ notes: string;
28
+ expectedGithubLogin: string | null;
29
+ assertTerminal: boolean;
30
+ dryRun: boolean;
31
+ }
32
+
33
+ export interface PullRequestView {
34
+ author: { login: string };
35
+ baseRefName: string;
36
+ body: string;
37
+ files: Array<{ path: string }>;
38
+ headRefOid: string;
39
+ isDraft: boolean;
40
+ state: string;
41
+ url: string;
42
+ }
43
+
44
+ export interface TrackerIssueView {
45
+ author: { login: string };
46
+ body: string | null;
47
+ labels: Array<{ name: string }>;
48
+ state: string;
49
+ title: string;
50
+ }
51
+
52
+ export interface ReviewResult {
53
+ schema_version: 1;
54
+ candidate_id: string;
55
+ skill: string;
56
+ outcome: "no_drift" | "pr_created" | "needs_human_review";
57
+ notes: string;
58
+ pr_url: string | null;
59
+ evidence: ReviewEvidence;
60
+ }
61
+
62
+ function parseArgs(argv: string[]): Args {
63
+ const args: Args = {
64
+ repo: DEFAULT_TARGET_REPO,
65
+ trackerIssue: null,
66
+ analysisFile: null,
67
+ resultFile: null,
68
+ candidateId: null,
69
+ outcome: null,
70
+ notes: "",
71
+ expectedGithubLogin: null,
72
+ assertTerminal: false,
73
+ dryRun: false,
74
+ };
75
+ for (let index = 0; index < argv.length; index += 1) {
76
+ const arg = argv[index];
77
+ if (arg === "--repo") args.repo = argv[++index] ?? args.repo;
78
+ else if (arg === "--tracker-issue") {
79
+ args.trackerIssue = Number(argv[++index]);
80
+ } else if (arg === "--analysis-file") {
81
+ args.analysisFile = argv[++index] ?? null;
82
+ } else if (arg === "--result-file") {
83
+ args.resultFile = argv[++index] ?? null;
84
+ } else if (arg === "--candidate-id") {
85
+ args.candidateId = argv[++index] ?? null;
86
+ } else if (arg === "--outcome") {
87
+ args.outcome = parseOutcome(argv[++index]);
88
+ } else if (arg === "--notes") args.notes = argv[++index] ?? "";
89
+ else if (arg === "--expected-github-login") {
90
+ args.expectedGithubLogin = argv[++index] ?? null;
91
+ } else if (arg === "--assert-terminal") args.assertTerminal = true;
92
+ else if (arg === "--dry-run") args.dryRun = true;
93
+ else if (arg === "--help" || arg === "-h") {
94
+ console.log(
95
+ "Usage: bun scripts/builtin-skills-watch/update-tracker.ts --tracker-issue ISSUE (--candidate-id ID --assert-terminal | --analysis-file FILE (--result-file FILE --expected-github-login LOGIN | --outcome error [--notes TEXT])) [--repo OWNER/REPO] [--dry-run]",
96
+ );
97
+ process.exit(0);
98
+ } else {
99
+ throw new Error(`Unknown argument: ${arg}`);
100
+ }
101
+ }
102
+
103
+ if (!args.trackerIssue || Number.isNaN(args.trackerIssue)) {
104
+ throw new Error("--tracker-issue is required");
105
+ }
106
+ if (args.assertTerminal) {
107
+ if (!args.candidateId) {
108
+ throw new Error("--candidate-id is required with --assert-terminal");
109
+ }
110
+ return args;
111
+ }
112
+ if (!args.analysisFile) throw new Error("--analysis-file is required");
113
+ if (args.resultFile) {
114
+ if (!args.expectedGithubLogin) {
115
+ throw new Error("--expected-github-login is required with --result-file");
116
+ }
117
+ } else if (args.outcome !== "error") {
118
+ throw new Error(
119
+ "Use --result-file for terminal outcomes or --outcome error",
120
+ );
121
+ }
122
+ return args;
123
+ }
124
+
125
+ function main(): void {
126
+ const args = parseArgs(process.argv.slice(2));
127
+ const tracker = getOpenTrackerIssue(args.repo, args.trackerIssue as number);
128
+ const state = parseTrackerState(tracker.body);
129
+
130
+ if (args.assertTerminal) {
131
+ if (!hasTerminalCandidate(state, args.candidateId as string)) {
132
+ throw new Error(
133
+ `Tracker has no terminal outcome for ${args.candidateId}`,
134
+ );
135
+ }
136
+ console.log(`Tracker recorded terminal outcome for ${args.candidateId}`);
137
+ return;
138
+ }
139
+
140
+ const receivedAnalysis = readAnalysis(args.analysisFile as string);
141
+ if (hasTerminalCandidate(state, receivedAnalysis.candidate_id)) {
142
+ console.log(
143
+ `Tracker already recorded terminal outcome for ${receivedAnalysis.candidate_id}`,
144
+ );
145
+ return;
146
+ }
147
+ const pending = state.pending[receivedAnalysis.skill];
148
+ if (!pending || pending.candidate_id !== receivedAnalysis.candidate_id) {
149
+ throw new Error(
150
+ `Candidate ${receivedAnalysis.candidate_id} is not pending for ${receivedAnalysis.skill}`,
151
+ );
152
+ }
153
+ const analysis = buildAnalysis({
154
+ skill: receivedAnalysis.skill,
155
+ currentSha: pending.current_sha,
156
+ auditAt: pending.audit_at,
157
+ previousAudit: previousAudit(state, receivedAnalysis.skill),
158
+ });
159
+ analysis.workflow_run_url = workflowRunUrl(pending.workflow_run_id);
160
+ assertAnalysisIdentity(receivedAnalysis, analysis);
161
+ const result = args.resultFile
162
+ ? readReviewResult(args.resultFile, analysis)
163
+ : {
164
+ outcome: "error" as const,
165
+ notes: args.notes || defaultNotes("error"),
166
+ pr_url: null,
167
+ evidence: null,
168
+ };
169
+ if (result.pr_url) {
170
+ verifyPullRequest(
171
+ args.repo,
172
+ result.pr_url,
173
+ analysis,
174
+ args.expectedGithubLogin as string,
175
+ );
176
+ }
177
+ const evidenceUrl = result.evidence
178
+ ? args.dryRun
179
+ ? `https://github.com/letta-ai/letta-code/issues/${args.trackerIssue}#issuecomment-1`
180
+ : createIssueComment(
181
+ args.repo,
182
+ args.trackerIssue as number,
183
+ renderEvidenceComment(analysis, result),
184
+ )
185
+ : null;
186
+ const next = recordOutcome(state, {
187
+ analysis,
188
+ outcome: result.outcome,
189
+ notes: result.notes,
190
+ prUrl: result.pr_url,
191
+ evidence: result.evidence,
192
+ evidenceUrl,
193
+ });
194
+ const inventory = [
195
+ ...new Set([
196
+ ...analysis.skill_inventory,
197
+ ...Object.keys(next.skills),
198
+ ...Object.keys(next.pending),
199
+ ]),
200
+ ].sort();
201
+ const nextBody = renderTrackerBody(next, inventory);
202
+ if (args.dryRun) console.log(nextBody);
203
+ else {
204
+ editIssueBody(args.repo, args.trackerIssue as number, nextBody);
205
+ console.log(
206
+ `Recorded ${analysis.candidate_id} as ${result.outcome} in #${args.trackerIssue}`,
207
+ );
208
+ }
209
+ }
210
+
211
+ function previousAudit(
212
+ state: ReturnType<typeof parseTrackerState>,
213
+ skill: string,
214
+ ): BuiltinSkillWatchAnalysis["previous_audit"] {
215
+ const audit = state.skills[skill];
216
+ return audit
217
+ ? {
218
+ candidate_id: audit.candidate_id,
219
+ audited_sha: audit.audited_sha,
220
+ skill_digest: audit.skill_digest,
221
+ audited_at: audit.audited_at,
222
+ }
223
+ : null;
224
+ }
225
+
226
+ function workflowRunUrl(runId: string): string {
227
+ return `https://github.com/letta-ai/letta-code/actions/runs/${runId}`;
228
+ }
229
+
230
+ function renderEvidenceComment(
231
+ analysis: BuiltinSkillWatchAnalysis,
232
+ result: ReviewResult,
233
+ ): string {
234
+ return [
235
+ `## Built-in skill audit evidence: ${analysis.skill}`,
236
+ "",
237
+ `Candidate: \`${analysis.candidate_id}\``,
238
+ `Outcome: \`${result.outcome}\``,
239
+ `Notes: ${result.notes}`,
240
+ `PR: ${result.pr_url ?? "-"}`,
241
+ "",
242
+ "```json",
243
+ JSON.stringify(result.evidence, null, 2),
244
+ "```",
245
+ ].join("\n");
246
+ }
247
+
248
+ export function getOpenTrackerIssue(
249
+ repo: string,
250
+ issueNumber: number,
251
+ ): { body: string } {
252
+ const issue = ghJson<TrackerIssueView>([
253
+ "issue",
254
+ "view",
255
+ String(issueNumber),
256
+ "--repo",
257
+ repo,
258
+ "--json",
259
+ "author,body,labels,state,title",
260
+ ]);
261
+ validateTrackerIssueView(issue);
262
+ return { body: issue.body ?? "" };
263
+ }
264
+
265
+ export function validateTrackerIssueView(issue: TrackerIssueView): void {
266
+ if (
267
+ issue.state !== "OPEN" ||
268
+ issue.title !== "Built-in skill staleness tracker" ||
269
+ !issue.labels.some((label) => label.name === "builtin-skills-watch") ||
270
+ (issue.author.login !== "app/github-actions" &&
271
+ issue.author.login !== "github-actions[bot]")
272
+ ) {
273
+ throw new Error("Built-in skills tracker issue is closed or invalid");
274
+ }
275
+ }
276
+
277
+ export function readAnalysis(path: string): BuiltinSkillWatchAnalysis {
278
+ const analysis = JSON.parse(
279
+ readFileSync(path, "utf8"),
280
+ ) as BuiltinSkillWatchAnalysis;
281
+ if (
282
+ analysis.schema_version !== 1 ||
283
+ typeof analysis.candidate_id !== "string" ||
284
+ typeof analysis.skill !== "string" ||
285
+ !Array.isArray(analysis.skill_inventory) ||
286
+ !analysis.skill_inventory.includes(analysis.skill)
287
+ ) {
288
+ throw new Error("Built-in skill analysis is invalid");
289
+ }
290
+ return analysis;
291
+ }
292
+
293
+ export function assertAnalysisIdentity(
294
+ received: BuiltinSkillWatchAnalysis,
295
+ rebuilt: BuiltinSkillWatchAnalysis,
296
+ ): void {
297
+ const fields: Array<keyof BuiltinSkillWatchAnalysis> = [
298
+ "candidate_id",
299
+ "skill",
300
+ "skill_path",
301
+ "skill_files",
302
+ "skill_digest",
303
+ "current_sha",
304
+ "audit_at",
305
+ "previous_audit",
306
+ "repository_changes",
307
+ "skill_inventory",
308
+ ];
309
+ for (const field of fields) {
310
+ if (JSON.stringify(received[field]) !== JSON.stringify(rebuilt[field])) {
311
+ throw new Error(
312
+ `Analysis field ${field} does not match the pending candidate`,
313
+ );
314
+ }
315
+ }
316
+ }
317
+
318
+ export function readReviewResult(
319
+ path: string,
320
+ analysis: BuiltinSkillWatchAnalysis,
321
+ ): ReviewResult {
322
+ const value = JSON.parse(readFileSync(path, "utf8")) as unknown;
323
+ return parseReviewResult(value, analysis);
324
+ }
325
+
326
+ export function parseReviewResult(
327
+ value: unknown,
328
+ analysis: BuiltinSkillWatchAnalysis,
329
+ ): ReviewResult {
330
+ if (
331
+ !isRecord(value) ||
332
+ !hasExactKeys(value, [
333
+ "schema_version",
334
+ "candidate_id",
335
+ "skill",
336
+ "outcome",
337
+ "notes",
338
+ "pr_url",
339
+ "evidence",
340
+ ]) ||
341
+ value.schema_version !== 1 ||
342
+ value.candidate_id !== analysis.candidate_id ||
343
+ value.skill !== analysis.skill ||
344
+ (value.outcome !== "no_drift" &&
345
+ value.outcome !== "pr_created" &&
346
+ value.outcome !== "needs_human_review") ||
347
+ typeof value.notes !== "string" ||
348
+ value.notes.length === 0 ||
349
+ value.notes.length > 120 ||
350
+ (value.pr_url !== null && typeof value.pr_url !== "string")
351
+ ) {
352
+ throw new Error("Review result does not match the pending candidate");
353
+ }
354
+ if (
355
+ (value.outcome === "pr_created") !==
356
+ (typeof value.pr_url === "string" && value.pr_url.length > 0)
357
+ ) {
358
+ throw new Error("Review result PR URL does not match its outcome");
359
+ }
360
+ const evidence = parseReviewEvidence(value.evidence);
361
+ if (
362
+ evidence.candidate_id !== analysis.candidate_id ||
363
+ evidence.skill !== analysis.skill
364
+ ) {
365
+ throw new Error("Review evidence does not match the pending candidate");
366
+ }
367
+ return {
368
+ schema_version: 1,
369
+ candidate_id: value.candidate_id as string,
370
+ skill: value.skill as string,
371
+ outcome: value.outcome,
372
+ notes: value.notes,
373
+ pr_url: value.pr_url,
374
+ evidence,
375
+ };
376
+ }
377
+
378
+ export function verifyPullRequest(
379
+ repo: string,
380
+ prUrl: string,
381
+ analysis: BuiltinSkillWatchAnalysis,
382
+ expectedGithubLogin: string,
383
+ ): void {
384
+ const authenticatedLogin = ghJson<{ login: string }>(["api", "user"]).login;
385
+ if (authenticatedLogin !== expectedGithubLogin) {
386
+ throw new Error(
387
+ `Authenticated GitHub login ${authenticatedLogin} does not match ${expectedGithubLogin}`,
388
+ );
389
+ }
390
+ const match = prUrl.match(
391
+ /^https:\/\/github\.com\/([^/]+\/[^/]+)\/pull\/(\d+)\/?$/,
392
+ );
393
+ if (!match || match[1] !== repo) {
394
+ throw new Error(`PR URL must belong to https://github.com/${repo}`);
395
+ }
396
+ const pullRequest = ghJson<PullRequestView>([
397
+ "pr",
398
+ "view",
399
+ match[2] as string,
400
+ "--repo",
401
+ repo,
402
+ "--json",
403
+ "author,baseRefName,body,files,headRefOid,isDraft,state,url",
404
+ ]);
405
+ validatePullRequestView(pullRequest, prUrl, analysis, expectedGithubLogin);
406
+ const comparison = ghJson<{
407
+ status: string;
408
+ merge_base_commit: { sha: string };
409
+ }>([
410
+ "api",
411
+ `repos/${repo}/compare/${analysis.current_sha}...${pullRequest.headRefOid}`,
412
+ ]);
413
+ if (
414
+ comparison.status !== "ahead" ||
415
+ comparison.merge_base_commit.sha !== analysis.current_sha
416
+ ) {
417
+ throw new Error("Watcher PR head is not based on the audited commit");
418
+ }
419
+ }
420
+
421
+ export function validatePullRequestView(
422
+ pullRequest: PullRequestView,
423
+ prUrl: string,
424
+ analysis: BuiltinSkillWatchAnalysis,
425
+ expectedGithubLogin: string,
426
+ ): void {
427
+ if (pullRequest.url !== prUrl.replace(/\/$/, "")) {
428
+ throw new Error(`PR URL mismatch: ${pullRequest.url}`);
429
+ }
430
+ if (pullRequest.author.login !== expectedGithubLogin) {
431
+ throw new Error(
432
+ `PR author ${pullRequest.author.login} does not match ${expectedGithubLogin}`,
433
+ );
434
+ }
435
+ if (pullRequest.state !== "OPEN" || !pullRequest.isDraft) {
436
+ throw new Error("Watcher PR must be open and draft");
437
+ }
438
+ if (pullRequest.baseRefName !== "main") {
439
+ throw new Error("Watcher PR must target main");
440
+ }
441
+ if (!/^[a-f0-9]{40}$/.test(pullRequest.headRefOid)) {
442
+ throw new Error("Watcher PR head commit is invalid");
443
+ }
444
+ if (
445
+ !pullRequest.body.includes(`Builtin-skill-watch: ${analysis.candidate_id}`)
446
+ ) {
447
+ throw new Error(
448
+ `PR body is missing Builtin-skill-watch: ${analysis.candidate_id}`,
449
+ );
450
+ }
451
+ const allowedTestPath = "src/agent/skills-discovery.test.ts";
452
+ if (
453
+ pullRequest.files.length === 0 ||
454
+ !pullRequest.files.some((file) =>
455
+ file.path.startsWith(`${analysis.skill_path}/`),
456
+ ) ||
457
+ pullRequest.files.some(
458
+ (file) =>
459
+ !file.path.startsWith(`${analysis.skill_path}/`) &&
460
+ file.path !== allowedTestPath,
461
+ )
462
+ ) {
463
+ throw new Error(
464
+ "Watcher PR changed files outside the selected skill scope",
465
+ );
466
+ }
467
+ }
468
+
469
+ function parseOutcome(value: string | undefined): TrackerOutcome {
470
+ if (
471
+ value === "no_drift" ||
472
+ value === "pr_created" ||
473
+ value === "needs_human_review" ||
474
+ value === "error"
475
+ ) {
476
+ return value;
477
+ }
478
+ throw new Error(`Unknown outcome: ${value}`);
479
+ }
480
+
481
+ function defaultNotes(outcome: TrackerOutcome): string {
482
+ switch (outcome) {
483
+ case "no_drift":
484
+ return "reviewed against current sources; no skill change needed";
485
+ case "pr_created":
486
+ return "opened a focused skill update PR";
487
+ case "needs_human_review":
488
+ return "review needs a human decision";
489
+ case "error":
490
+ return "agent review failed before recording a terminal outcome";
491
+ }
492
+ }
493
+
494
+ function isRecord(value: unknown): value is Record<string, unknown> {
495
+ return typeof value === "object" && value !== null && !Array.isArray(value);
496
+ }
497
+
498
+ function hasExactKeys(
499
+ value: Record<string, unknown>,
500
+ expected: string[],
501
+ ): boolean {
502
+ return (
503
+ JSON.stringify(Object.keys(value).sort()) ===
504
+ JSON.stringify([...expected].sort())
505
+ );
506
+ }
507
+
508
+ if (import.meta.main) main();
@@ -2,6 +2,7 @@ import {
2
2
  ClaudeReleaseSourceDisagreementError,
3
3
  parseClaudeGitHubReleases,
4
4
  parseClaudeNpmMetadata,
5
+ releaseNotesForRange,
5
6
  selectClaudeReleaseCandidate,
6
7
  } from "./release-source.ts";
7
8
  import type { ClaudeGitHubRelease, ClaudeNpmMetadata } from "./types.ts";
@@ -109,15 +110,19 @@ describe("selectClaudeReleaseCandidate", () => {
109
110
  }
110
111
  });
111
112
 
112
- test("takes the oldest release after the terminal processed version", () => {
113
+ test("takes the latest release after the terminal processed version", () => {
113
114
  const candidate = selectClaudeReleaseCandidate({
114
115
  githubReleases: releases,
115
116
  npmMetadata: npm(),
116
117
  processedPackageVersions: ["1.0.0"],
117
118
  });
118
119
 
119
- expect(candidate?.version).toBe("1.1.0");
120
- expect(candidate?.integrity).toBe("sha512-1.1.0");
120
+ expect(candidate?.version).toBe("1.2.0");
121
+ expect(candidate?.integrity).toBe("sha512-1.2.0");
122
+ expect(candidate?.release_notes_md).toContain("## [1.1.0]");
123
+ expect(candidate?.release_notes_md).toContain("notes 1.1.0");
124
+ expect(candidate?.release_notes_md).toContain("## [1.2.0]");
125
+ expect(candidate?.release_notes_md).not.toContain("notes 1.0.0");
121
126
  });
122
127
 
123
128
  test("bootstrap selects only the current npm latest release", () => {
@@ -139,7 +144,8 @@ describe("selectClaudeReleaseCandidate", () => {
139
144
  });
140
145
 
141
146
  expect(candidate?.version).toBe("1.2.0");
142
- expect(candidate?.release_notes_md).toBe("notes 1.2.0");
147
+ expect(candidate?.release_notes_md).toContain("notes 1.1.0");
148
+ expect(candidate?.release_notes_md).toContain("notes 1.2.0");
143
149
  });
144
150
 
145
151
  test("validation can replay exact npm versions omitted from the GitHub feed", () => {
@@ -176,4 +182,10 @@ describe("selectClaudeReleaseCandidate", () => {
176
182
  }),
177
183
  ).toBeNull();
178
184
  });
185
+
186
+ test("rejects a reversed release-note range", () => {
187
+ expect(() => releaseNotesForRange(releases, "1.2.0", "1.1.0")).toThrow(
188
+ "Previous Claude release 1.2.0 must precede 1.1.0",
189
+ );
190
+ });
179
191
  });
@@ -275,7 +275,10 @@ export function selectClaudeReleaseCandidate(
275
275
  `Terminal version ${terminalVersion} is not present in GitHub releases.`,
276
276
  );
277
277
  }
278
- release = githubReleases[terminalIndex + 1];
278
+ release =
279
+ terminalIndex === githubReleases.length - 1
280
+ ? undefined
281
+ : githubReleases.at(-1);
279
282
  }
280
283
 
281
284
  if (!release) return null;
@@ -290,12 +293,48 @@ export function selectClaudeReleaseCandidate(
290
293
  return {
291
294
  ...npmVersion,
292
295
  release_url: release.html_url,
293
- release_notes_md: release.body ?? "",
296
+ release_notes_md: releaseNotesForRange(
297
+ githubReleases,
298
+ terminalVersion,
299
+ release.tag_name,
300
+ ),
294
301
  release_published_at: release.published_at,
295
302
  dist_tags: options.npmMetadata.dist_tags,
296
303
  };
297
304
  }
298
305
 
306
+ export function releaseNotesForRange(
307
+ releases: ClaudeGitHubRelease[],
308
+ previousVersion: string | null,
309
+ currentVersion: string,
310
+ ): string {
311
+ const currentIndex = releases.findIndex(
312
+ (release) => release.tag_name === currentVersion,
313
+ );
314
+ if (currentIndex < 0) {
315
+ throw new Error(`Could not find Claude release ${currentVersion}`);
316
+ }
317
+ const previousIndex = previousVersion
318
+ ? releases.findIndex((release) => release.tag_name === previousVersion)
319
+ : -1;
320
+ if (previousIndex >= currentIndex) {
321
+ throw new Error(
322
+ `Previous Claude release ${previousVersion} must precede ${currentVersion}`,
323
+ );
324
+ }
325
+ const included = releases.slice(
326
+ previousIndex < 0 ? currentIndex : previousIndex + 1,
327
+ currentIndex + 1,
328
+ );
329
+ if (included.length === 1) return included[0]?.body ?? "";
330
+ return included
331
+ .map(
332
+ (release) =>
333
+ `## [${release.tag_name}](${release.html_url})\n\n${release.body?.trim() || "_No release notes._"}`,
334
+ )
335
+ .join("\n\n");
336
+ }
337
+
299
338
  async function fetchJson(
300
339
  url: string,
301
340
  options: ReleaseSourceFetchOptions,
@@ -17,7 +17,7 @@ import {
17
17
  import {
18
18
  analyzeCodexRelease,
19
19
  DEFAULT_TARGET_REPO,
20
- findNextStableRelease,
20
+ findLatestStableReleaseAfter,
21
21
  listStableReleases,
22
22
  type Release,
23
23
  } from "./release-analysis.ts";
@@ -104,8 +104,11 @@ async function main() {
104
104
  const cursorTag = getCodexAuditCursorTag(state);
105
105
  if (cursorTag) {
106
106
  stableReleases ??= await listStableReleases();
107
- const nextRelease = findNextStableRelease(stableReleases, cursorTag);
108
- if (!nextRelease) {
107
+ const latestRelease = findLatestStableReleaseAfter(
108
+ stableReleases,
109
+ cursorTag,
110
+ );
111
+ if (!latestRelease) {
109
112
  console.log(`No stable release after ${cursorTag}; nothing to do.`);
110
113
  writeOutput("tracker_issue", String(tracker.number));
111
114
  writeOutput("tracker_issue_url", tracker.url);
@@ -116,7 +119,7 @@ async function main() {
116
119
  return;
117
120
  }
118
121
  sinceTag = cursorTag;
119
- currentTag = nextRelease.tag_name;
122
+ currentTag = latestRelease.tag_name;
120
123
  }
121
124
  }
122
125
 
@@ -142,7 +145,7 @@ async function main() {
142
145
  state,
143
146
  analysis.previous_tag,
144
147
  analysis.current_tag,
145
- analysis.is_adjacent_release,
148
+ analysis.is_latest_release,
146
149
  );
147
150
  if (next !== state) {
148
151
  editIssueBody(args.repo, tracker.number, renderTrackerBody(next));