@letta-ai/letta-code 0.30.32 → 0.31.1

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 (65) 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/agent/subagents/manager.d.ts.map +1 -1
  20. package/dist/types/channels/gateway-core.d.ts.map +1 -1
  21. package/dist/types/channels/route-thread-key.d.ts +20 -0
  22. package/dist/types/channels/route-thread-key.d.ts.map +1 -0
  23. package/dist/types/channels-public.d.ts +1 -0
  24. package/dist/types/channels-public.d.ts.map +1 -1
  25. package/dist/types/tools/impl/shell-runner.d.ts.map +1 -1
  26. package/dist/types/tools/impl/skill.d.ts.map +1 -1
  27. package/dist/types/types/protocol_v2.d.ts +3 -144
  28. package/dist/types/types/protocol_v2.d.ts.map +1 -1
  29. package/dist/types/types/schedule-protocol.d.ts +151 -1
  30. package/dist/types/types/schedule-protocol.d.ts.map +1 -1
  31. package/dist/types/utils/frontmatter.d.ts.map +1 -1
  32. package/dist/types/utils/systemd-workload-scope.d.ts +15 -0
  33. package/dist/types/utils/systemd-workload-scope.d.ts.map +1 -0
  34. package/dist/types/websocket/listener/cwd-change.d.ts.map +1 -1
  35. package/letta.js +275 -94
  36. package/package.json +4 -2
  37. package/scripts/builtin-skills-watch/agent-watch.test.ts +104 -0
  38. package/scripts/builtin-skills-watch/agent-watch.ts +337 -0
  39. package/scripts/builtin-skills-watch/aggregate-results.test.ts +79 -0
  40. package/scripts/builtin-skills-watch/aggregate-results.ts +326 -0
  41. package/scripts/builtin-skills-watch/analysis.test.ts +70 -0
  42. package/scripts/builtin-skills-watch/analysis.ts +228 -0
  43. package/scripts/builtin-skills-watch/evidence.test.ts +114 -0
  44. package/scripts/builtin-skills-watch/evidence.ts +145 -0
  45. package/scripts/builtin-skills-watch/github.ts +161 -0
  46. package/scripts/builtin-skills-watch/tracker.test.ts +249 -0
  47. package/scripts/builtin-skills-watch/tracker.ts +506 -0
  48. package/scripts/builtin-skills-watch/update-tracker.test.ts +234 -0
  49. package/scripts/builtin-skills-watch/update-tracker.ts +508 -0
  50. package/scripts/claude-watch/release-source.test.ts +16 -4
  51. package/scripts/claude-watch/release-source.ts +41 -2
  52. package/scripts/codex-watch/agent-watch.ts +8 -5
  53. package/scripts/codex-watch/release-analysis.test.ts +18 -16
  54. package/scripts/codex-watch/release-analysis.ts +31 -9
  55. package/scripts/codex-watch/tracker.test.ts +5 -5
  56. package/scripts/codex-watch/tracker.ts +7 -8
  57. package/scripts/pi-ai-watch/agent-watch.ts +9 -14
  58. package/scripts/pi-ai-watch/release-analysis.test.ts +24 -18
  59. package/scripts/pi-ai-watch/release-analysis.ts +85 -45
  60. package/scripts/pi-ai-watch/tracker.test.ts +25 -18
  61. package/scripts/pi-ai-watch/tracker.ts +7 -19
  62. package/scripts/pi-ai-watch/update-tracker.ts +2 -2
  63. package/scripts/source-file-size-baseline.json +2 -2
  64. package/skills/dispatching-coding-agents/SKILL.md +16 -7
  65. package/skills/syncing-memory-filesystem/SKILL.md +118 -226
@@ -0,0 +1,326 @@
1
+ #!/usr/bin/env bun
2
+ /** Validates a daily skill-review batch and updates the tracker once. */
3
+
4
+ import { existsSync, readFileSync } from "node:fs";
5
+ import { join } from "node:path";
6
+ import {
7
+ type BuiltinSkillWatchAnalysis,
8
+ buildAnalysis,
9
+ DEFAULT_TARGET_REPO,
10
+ } from "./analysis.ts";
11
+ import { createIssueComment, editIssueBody } from "./github.ts";
12
+ import {
13
+ hasTerminalCandidate,
14
+ parseTrackerState,
15
+ recordOutcome,
16
+ renderTrackerBody,
17
+ } from "./tracker.ts";
18
+ import {
19
+ assertAnalysisIdentity,
20
+ getOpenTrackerIssue,
21
+ type ReviewResult,
22
+ readAnalysis,
23
+ readReviewResult,
24
+ verifyPullRequest,
25
+ } from "./update-tracker.ts";
26
+
27
+ interface Args {
28
+ repo: string;
29
+ trackerIssue: number | null;
30
+ manifestFile: string | null;
31
+ analysisDir: string | null;
32
+ resultsDir: string | null;
33
+ expectedGithubLogin: string | null;
34
+ dryRun: boolean;
35
+ }
36
+
37
+ interface Manifest {
38
+ schema_version: 1;
39
+ tracker_issue: number;
40
+ inventory: string[];
41
+ candidates: Array<{ skill: string; candidate_id: string }>;
42
+ }
43
+
44
+ export interface ValidatedOutcome {
45
+ analysis: BuiltinSkillWatchAnalysis;
46
+ result: ReviewResult | null;
47
+ }
48
+
49
+ export interface EvidenceCommentBatch {
50
+ candidateIds: string[];
51
+ body: string;
52
+ }
53
+
54
+ function parseArgs(argv: string[]): Args {
55
+ const args: Args = {
56
+ repo: DEFAULT_TARGET_REPO,
57
+ trackerIssue: null,
58
+ manifestFile: null,
59
+ analysisDir: null,
60
+ resultsDir: null,
61
+ expectedGithubLogin: null,
62
+ dryRun: false,
63
+ };
64
+ for (let index = 0; index < argv.length; index += 1) {
65
+ const arg = argv[index];
66
+ if (arg === "--repo") args.repo = argv[++index] ?? args.repo;
67
+ else if (arg === "--tracker-issue") {
68
+ args.trackerIssue = Number(argv[++index]);
69
+ } else if (arg === "--manifest-file") {
70
+ args.manifestFile = argv[++index] ?? null;
71
+ } else if (arg === "--analysis-dir") {
72
+ args.analysisDir = argv[++index] ?? null;
73
+ } else if (arg === "--results-dir") {
74
+ args.resultsDir = argv[++index] ?? null;
75
+ } else if (arg === "--expected-github-login") {
76
+ args.expectedGithubLogin = argv[++index] ?? null;
77
+ } else if (arg === "--dry-run") args.dryRun = true;
78
+ else throw new Error(`Unknown argument: ${arg}`);
79
+ }
80
+ if (!args.trackerIssue || Number.isNaN(args.trackerIssue)) {
81
+ throw new Error("--tracker-issue is required");
82
+ }
83
+ if (
84
+ !args.manifestFile ||
85
+ !args.analysisDir ||
86
+ !args.resultsDir ||
87
+ !args.expectedGithubLogin
88
+ ) {
89
+ throw new Error(
90
+ "--manifest-file, --analysis-dir, --results-dir, and --expected-github-login are required",
91
+ );
92
+ }
93
+ return args;
94
+ }
95
+
96
+ function main(): void {
97
+ const args = parseArgs(process.argv.slice(2));
98
+ const manifest = readManifest(args.manifestFile as string);
99
+ if (manifest.tracker_issue !== args.trackerIssue) {
100
+ throw new Error("Manifest tracker issue does not match the workflow input");
101
+ }
102
+ const original = getOpenTrackerIssue(args.repo, args.trackerIssue as number);
103
+ let state = parseTrackerState(original.body);
104
+ let failed = false;
105
+ const outcomes: ValidatedOutcome[] = [];
106
+
107
+ for (const candidate of manifest.candidates) {
108
+ const analysisPath = join(
109
+ args.analysisDir as string,
110
+ `${candidate.skill}.json`,
111
+ );
112
+ const received = readAnalysis(analysisPath);
113
+ if (
114
+ received.candidate_id !== candidate.candidate_id ||
115
+ received.skill !== candidate.skill
116
+ ) {
117
+ throw new Error(`Manifest entry for ${candidate.skill} is invalid`);
118
+ }
119
+ if (hasTerminalCandidate(state, received.candidate_id)) continue;
120
+ const pending = state.pending[received.skill];
121
+ if (!pending || pending.candidate_id !== received.candidate_id) {
122
+ throw new Error(`Candidate ${received.candidate_id} is not pending`);
123
+ }
124
+ const analysis = buildAnalysis({
125
+ skill: received.skill,
126
+ currentSha: pending.current_sha,
127
+ auditAt: pending.audit_at,
128
+ previousAudit: previousAudit(state, received.skill),
129
+ });
130
+ analysis.workflow_run_url = workflowRunUrl(pending.workflow_run_id);
131
+ assertAnalysisIdentity(received, analysis);
132
+
133
+ const resultPath = join(
134
+ args.resultsDir as string,
135
+ `builtin-skill-result-${received.skill}`,
136
+ "result.json",
137
+ );
138
+ try {
139
+ if (!existsSync(resultPath)) {
140
+ throw new Error("Amelia result artifact is missing");
141
+ }
142
+ const result = readReviewResult(resultPath, analysis);
143
+ if (result.pr_url) {
144
+ verifyPullRequest(
145
+ args.repo,
146
+ result.pr_url,
147
+ analysis,
148
+ args.expectedGithubLogin as string,
149
+ );
150
+ }
151
+ outcomes.push({ analysis, result });
152
+ } catch (error) {
153
+ failed = true;
154
+ console.error(`${received.skill}: ${errorMessage(error)}`);
155
+ outcomes.push({ analysis, result: null });
156
+ }
157
+ }
158
+
159
+ assertTrackerUnchanged(args, original.body);
160
+ const evidenceUrls = args.dryRun
161
+ ? dryRunEvidenceUrls(outcomes, args.trackerIssue as number)
162
+ : postEvidenceComments(args.repo, args.trackerIssue as number, outcomes);
163
+ for (const outcome of outcomes) {
164
+ if (outcome.result) {
165
+ state = recordOutcome(state, {
166
+ analysis: outcome.analysis,
167
+ outcome: outcome.result.outcome,
168
+ notes: outcome.result.notes,
169
+ prUrl: outcome.result.pr_url,
170
+ evidence: outcome.result.evidence,
171
+ evidenceUrl: evidenceUrls.get(outcome.analysis.candidate_id),
172
+ });
173
+ } else {
174
+ state = recordOutcome(state, {
175
+ analysis: outcome.analysis,
176
+ outcome: "error",
177
+ notes: "Amelia review failed before a valid result; retry this skill",
178
+ });
179
+ }
180
+ }
181
+
182
+ const nextBody = renderTrackerBody(state, manifest.inventory);
183
+ if (args.dryRun) console.log(nextBody);
184
+ else {
185
+ assertTrackerUnchanged(args, original.body);
186
+ editIssueBody(args.repo, args.trackerIssue as number, nextBody);
187
+ }
188
+ if (failed) process.exit(1);
189
+ }
190
+
191
+ function previousAudit(
192
+ state: ReturnType<typeof parseTrackerState>,
193
+ skill: string,
194
+ ): BuiltinSkillWatchAnalysis["previous_audit"] {
195
+ const audit = state.skills[skill];
196
+ return audit
197
+ ? {
198
+ candidate_id: audit.candidate_id,
199
+ audited_sha: audit.audited_sha,
200
+ skill_digest: audit.skill_digest,
201
+ audited_at: audit.audited_at,
202
+ }
203
+ : null;
204
+ }
205
+
206
+ function workflowRunUrl(runId: string): string {
207
+ return `https://github.com/letta-ai/letta-code/actions/runs/${runId}`;
208
+ }
209
+
210
+ function assertTrackerUnchanged(args: Args, expectedBody: string): void {
211
+ const latest = getOpenTrackerIssue(args.repo, args.trackerIssue as number);
212
+ if (latest.body !== expectedBody) {
213
+ throw new Error("Tracker body changed while results were being validated");
214
+ }
215
+ }
216
+
217
+ function postEvidenceComments(
218
+ repo: string,
219
+ issueNumber: number,
220
+ outcomes: ValidatedOutcome[],
221
+ ): Map<string, string> {
222
+ const urls = new Map<string, string>();
223
+ for (const batch of buildEvidenceCommentBatches(outcomes)) {
224
+ const url = createIssueComment(repo, issueNumber, batch.body);
225
+ for (const candidateId of batch.candidateIds) urls.set(candidateId, url);
226
+ }
227
+ return urls;
228
+ }
229
+
230
+ export function buildEvidenceCommentBatches(
231
+ outcomes: ValidatedOutcome[],
232
+ ): EvidenceCommentBatch[] {
233
+ const batches: EvidenceCommentBatch[] = [];
234
+ let blocks: Array<{ candidateId: string; text: string }> = [];
235
+ let bytes = 0;
236
+ const flush = (): void => {
237
+ if (blocks.length === 0) return;
238
+ const body = [
239
+ "## Built-in skill audit evidence",
240
+ "",
241
+ ...blocks.map((block) => block.text),
242
+ ].join("\n");
243
+ batches.push({
244
+ candidateIds: blocks.map((block) => block.candidateId),
245
+ body,
246
+ });
247
+ blocks = [];
248
+ bytes = 0;
249
+ };
250
+ for (const outcome of terminalOutcomes(outcomes)) {
251
+ const block = renderEvidenceBlock(outcome.analysis, outcome.result);
252
+ const blockBytes = Buffer.byteLength(block, "utf8");
253
+ if (bytes + blockBytes > 60_000) flush();
254
+ blocks.push({ candidateId: outcome.analysis.candidate_id, text: block });
255
+ bytes += blockBytes;
256
+ }
257
+ flush();
258
+ return batches;
259
+ }
260
+
261
+ function dryRunEvidenceUrls(
262
+ outcomes: ValidatedOutcome[],
263
+ issueNumber: number,
264
+ ): Map<string, string> {
265
+ return new Map(
266
+ terminalOutcomes(outcomes).map((outcome) => [
267
+ outcome.analysis.candidate_id,
268
+ `https://github.com/letta-ai/letta-code/issues/${issueNumber}#issuecomment-1`,
269
+ ]),
270
+ );
271
+ }
272
+
273
+ function terminalOutcomes(
274
+ outcomes: ValidatedOutcome[],
275
+ ): Array<ValidatedOutcome & { result: ReviewResult }> {
276
+ return outcomes.filter(
277
+ (outcome): outcome is ValidatedOutcome & { result: ReviewResult } =>
278
+ outcome.result !== null,
279
+ );
280
+ }
281
+
282
+ function renderEvidenceBlock(
283
+ analysis: BuiltinSkillWatchAnalysis,
284
+ result: ReviewResult,
285
+ ): string {
286
+ return [
287
+ `<details><summary>${analysis.skill}: ${result.outcome}</summary>`,
288
+ "",
289
+ `Candidate: \`${analysis.candidate_id}\``,
290
+ `Outcome: \`${result.outcome}\``,
291
+ `Notes: ${result.notes}`,
292
+ `PR: ${result.pr_url ?? "-"}`,
293
+ "",
294
+ "```json",
295
+ JSON.stringify(result.evidence, null, 2),
296
+ "```",
297
+ "",
298
+ "</details>",
299
+ "",
300
+ ].join("\n");
301
+ }
302
+
303
+ function readManifest(path: string): Manifest {
304
+ const value = JSON.parse(readFileSync(path, "utf8")) as Manifest;
305
+ if (
306
+ value.schema_version !== 1 ||
307
+ !Number.isInteger(value.tracker_issue) ||
308
+ !Array.isArray(value.inventory) ||
309
+ !Array.isArray(value.candidates) ||
310
+ value.candidates.some(
311
+ (candidate) =>
312
+ typeof candidate.skill !== "string" ||
313
+ typeof candidate.candidate_id !== "string" ||
314
+ !value.inventory.includes(candidate.skill),
315
+ )
316
+ ) {
317
+ throw new Error("Built-in skill watch manifest is invalid");
318
+ }
319
+ return value;
320
+ }
321
+
322
+ function errorMessage(error: unknown): string {
323
+ return error instanceof Error ? error.message : String(error);
324
+ }
325
+
326
+ if (import.meta.main) main();
@@ -0,0 +1,70 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import {
3
+ buildAnalysis,
4
+ collectSkillFilesAtCommit,
5
+ listBuiltinSkillsAtCommit,
6
+ } from "./analysis.ts";
7
+
8
+ const HEAD = gitHead();
9
+ const AUDIT_AT = "2026-08-26T00:00:00.000Z";
10
+
11
+ describe("bundled skill inventory", () => {
12
+ test("lists bundled skill directories from an exact commit", () => {
13
+ const skills = listBuiltinSkillsAtCommit(HEAD);
14
+
15
+ expect(skills).toContain("creating-skills");
16
+ expect(skills).toContain("syncing-memory-filesystem");
17
+ expect(skills).toEqual([...skills].sort());
18
+ expect(new Set(skills).size).toBe(skills.length);
19
+ });
20
+
21
+ test("includes every tracked file in the selected skill digest", () => {
22
+ const files = collectSkillFilesAtCommit(HEAD, "creating-skills");
23
+
24
+ expect(files).toContain("src/skills/builtin/creating-skills/SKILL.md");
25
+ expect(files.some((path) => path.includes("/scripts/"))).toBe(true);
26
+ });
27
+ });
28
+
29
+ describe("buildAnalysis", () => {
30
+ test("builds a reproducible exact-commit candidate", () => {
31
+ const first = buildAnalysis({
32
+ skill: "syncing-memory-filesystem",
33
+ currentSha: HEAD,
34
+ auditAt: AUDIT_AT,
35
+ });
36
+ const second = buildAnalysis({
37
+ skill: "syncing-memory-filesystem",
38
+ currentSha: HEAD,
39
+ auditAt: AUDIT_AT,
40
+ });
41
+
42
+ expect(second).toEqual(first);
43
+ expect(first.candidate_id).toStartWith(
44
+ `syncing-memory-filesystem@${HEAD.slice(0, 12)}-`,
45
+ );
46
+ expect(first.skill_digest).toMatch(/^[a-f0-9]{64}$/);
47
+ expect(first.skill_files).toContain(
48
+ "src/skills/builtin/syncing-memory-filesystem/SKILL.md",
49
+ );
50
+ expect(first.skill_inventory).toContain("syncing-memory-filesystem");
51
+ });
52
+
53
+ test("rejects an unknown skill", () => {
54
+ expect(() =>
55
+ buildAnalysis({
56
+ skill: "not-a-bundled-skill",
57
+ currentSha: HEAD,
58
+ auditAt: AUDIT_AT,
59
+ }),
60
+ ).toThrow("does not exist");
61
+ });
62
+ });
63
+
64
+ function gitHead(): string {
65
+ const result = Bun.spawnSync(["git", "rev-parse", "HEAD"], {
66
+ stdout: "pipe",
67
+ });
68
+ if (result.exitCode !== 0) throw new Error("Could not resolve HEAD");
69
+ return result.stdout.toString().trim();
70
+ }
@@ -0,0 +1,228 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { createHash } from "node:crypto";
3
+
4
+ export const DEFAULT_TARGET_REPO = "letta-ai/letta-code";
5
+ export const BUILTIN_SKILLS_DIR = "src/skills/builtin";
6
+
7
+ export interface PriorSkillAudit {
8
+ candidate_id: string;
9
+ audited_sha: string;
10
+ skill_digest: string;
11
+ audited_at: string;
12
+ }
13
+
14
+ export interface RepositoryChanges {
15
+ previous_sha: string | null;
16
+ changed_files: string[];
17
+ commits: string[];
18
+ history_available: boolean;
19
+ truncated: boolean;
20
+ }
21
+
22
+ export interface BuiltinSkillWatchAnalysis {
23
+ schema_version: 1;
24
+ candidate_id: string;
25
+ skill: string;
26
+ skill_path: string;
27
+ skill_files: string[];
28
+ skill_digest: string;
29
+ current_sha: string;
30
+ audit_at: string;
31
+ previous_audit: PriorSkillAudit | null;
32
+ repository_changes: RepositoryChanges;
33
+ skill_inventory: string[];
34
+ workflow_run_url: string;
35
+ }
36
+
37
+ export interface BuildAnalysisOptions {
38
+ skill: string;
39
+ currentSha: string;
40
+ auditAt: string;
41
+ previousAudit?: PriorSkillAudit | null;
42
+ }
43
+
44
+ export function listBuiltinSkillsAtCommit(commit: string): string[] {
45
+ const files = runGit([
46
+ "ls-tree",
47
+ "-r",
48
+ "--name-only",
49
+ commit,
50
+ "--",
51
+ BUILTIN_SKILLS_DIR,
52
+ ]);
53
+ return files
54
+ .split("\n")
55
+ .map(
56
+ (path) => path.match(/^src\/skills\/builtin\/([^/]+)\/SKILL\.md$/)?.[1],
57
+ )
58
+ .filter((skill): skill is string => skill !== undefined)
59
+ .sort();
60
+ }
61
+
62
+ export function collectSkillFilesAtCommit(
63
+ commit: string,
64
+ skill: string,
65
+ ): string[] {
66
+ assertSkillName(skill);
67
+ const prefix = `${BUILTIN_SKILLS_DIR}/${skill}`;
68
+ return runGit(["ls-tree", "-r", "--name-only", commit, "--", prefix])
69
+ .split("\n")
70
+ .filter(Boolean)
71
+ .sort();
72
+ }
73
+
74
+ export function buildAnalysis(
75
+ options: BuildAnalysisOptions,
76
+ ): BuiltinSkillWatchAnalysis {
77
+ const currentSha = resolveCommit(options.currentSha);
78
+ assertAuditAt(options.auditAt);
79
+ const inventory = listBuiltinSkillsAtCommit(currentSha);
80
+ if (!inventory.includes(options.skill)) {
81
+ throw new Error(
82
+ `Bundled skill ${options.skill} does not exist at ${currentSha}`,
83
+ );
84
+ }
85
+
86
+ const skillFiles = collectSkillFilesAtCommit(currentSha, options.skill);
87
+ const skillDigest = digestFiles(currentSha, skillFiles);
88
+ const runUrl = workflowRunUrl();
89
+ const candidateHash = createHash("sha256")
90
+ .update(options.skill)
91
+ .update("\0")
92
+ .update(currentSha)
93
+ .update("\0")
94
+ .update(skillDigest)
95
+ .update("\0")
96
+ .update(options.auditAt)
97
+ .digest("hex")
98
+ .slice(0, 16);
99
+ const candidateId = `${options.skill}@${currentSha.slice(0, 12)}-${candidateHash}`;
100
+
101
+ return {
102
+ schema_version: 1,
103
+ candidate_id: candidateId,
104
+ skill: options.skill,
105
+ skill_path: `${BUILTIN_SKILLS_DIR}/${options.skill}`,
106
+ skill_files: skillFiles,
107
+ skill_digest: skillDigest,
108
+ current_sha: currentSha,
109
+ audit_at: options.auditAt,
110
+ previous_audit: options.previousAudit ?? null,
111
+ repository_changes: collectRepositoryChanges(
112
+ options.previousAudit?.audited_sha ?? null,
113
+ currentSha,
114
+ ),
115
+ skill_inventory: inventory,
116
+ workflow_run_url: runUrl,
117
+ };
118
+ }
119
+
120
+ function digestFiles(commit: string, files: string[]): string {
121
+ const hash = createHash("sha256");
122
+ for (const path of files) {
123
+ hash.update(path);
124
+ hash.update("\0");
125
+ hash.update(runGitBuffer(["show", `${commit}:${path}`]));
126
+ hash.update("\0");
127
+ }
128
+ return hash.digest("hex");
129
+ }
130
+
131
+ function collectRepositoryChanges(
132
+ previousSha: string | null,
133
+ currentSha: string,
134
+ ): RepositoryChanges {
135
+ if (!previousSha || previousSha === currentSha) {
136
+ return {
137
+ previous_sha: previousSha,
138
+ changed_files: [],
139
+ commits: [],
140
+ history_available: previousSha === currentSha,
141
+ truncated: false,
142
+ };
143
+ }
144
+
145
+ const ancestor = spawnSync(
146
+ "git",
147
+ ["merge-base", "--is-ancestor", previousSha, currentSha],
148
+ { encoding: "utf8" },
149
+ );
150
+ if (ancestor.status !== 0) {
151
+ return {
152
+ previous_sha: previousSha,
153
+ changed_files: [],
154
+ commits: [],
155
+ history_available: false,
156
+ truncated: false,
157
+ };
158
+ }
159
+
160
+ const allFiles = runGit([
161
+ "diff",
162
+ "--name-only",
163
+ `${previousSha}..${currentSha}`,
164
+ ])
165
+ .split("\n")
166
+ .filter(Boolean);
167
+ const allCommits = runGit([
168
+ "log",
169
+ "--format=%H %s",
170
+ `${previousSha}..${currentSha}`,
171
+ ])
172
+ .split("\n")
173
+ .filter(Boolean);
174
+ return {
175
+ previous_sha: previousSha,
176
+ changed_files: allFiles.slice(0, 500),
177
+ commits: allCommits.slice(0, 100),
178
+ history_available: true,
179
+ truncated: allFiles.length > 500 || allCommits.length > 100,
180
+ };
181
+ }
182
+
183
+ function runGit(args: string[], trim = true): string {
184
+ const result = spawnSync("git", args, {
185
+ encoding: "utf8",
186
+ maxBuffer: 50 * 1024 * 1024,
187
+ });
188
+ if (result.status !== 0) {
189
+ throw new Error(`git ${args.join(" ")} failed:\n${result.stderr}`);
190
+ }
191
+ return trim ? result.stdout.trim() : result.stdout;
192
+ }
193
+
194
+ function runGitBuffer(args: string[]): Buffer {
195
+ const result = spawnSync("git", args, {
196
+ maxBuffer: 50 * 1024 * 1024,
197
+ });
198
+ if (result.status !== 0) {
199
+ throw new Error(`git ${args.join(" ")} failed:\n${result.stderr}`);
200
+ }
201
+ return result.stdout;
202
+ }
203
+
204
+ function resolveCommit(commit: string): string {
205
+ return runGit(["rev-parse", `${commit}^{commit}`]);
206
+ }
207
+
208
+ function assertSkillName(skill: string): void {
209
+ if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(skill)) {
210
+ throw new Error(`Invalid bundled skill name: ${skill}`);
211
+ }
212
+ }
213
+
214
+ function assertAuditAt(auditAt: string): void {
215
+ const parsed = new Date(auditAt);
216
+ if (Number.isNaN(parsed.getTime()) || parsed.toISOString() !== auditAt) {
217
+ throw new Error(`Invalid audit timestamp: ${auditAt}`);
218
+ }
219
+ }
220
+
221
+ function workflowRunUrl(): string {
222
+ const server = process.env.GITHUB_SERVER_URL;
223
+ const repository = process.env.GITHUB_REPOSITORY;
224
+ const runId = process.env.GITHUB_RUN_ID;
225
+ return server && repository && runId
226
+ ? `${server}/${repository}/actions/runs/${runId}`
227
+ : "";
228
+ }