@wasabeef/agentnote 0.1.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 (2) hide show
  1. package/dist/cli.js +1138 -0
  2. package/package.json +38 -0
package/dist/cli.js ADDED
@@ -0,0 +1,1138 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/commands/init.ts
4
+ import { mkdir as mkdir2, writeFile as writeFile2 } from "node:fs/promises";
5
+ import { existsSync as existsSync2 } from "node:fs";
6
+ import { join as join3 } from "node:path";
7
+
8
+ // src/paths.ts
9
+ import { join } from "node:path";
10
+
11
+ // src/git.ts
12
+ import { execFile } from "node:child_process";
13
+ import { promisify } from "node:util";
14
+ var execFileAsync = promisify(execFile);
15
+ async function git(args2, options) {
16
+ const { stdout } = await execFileAsync("git", args2, {
17
+ cwd: options?.cwd,
18
+ encoding: "utf-8"
19
+ });
20
+ return stdout.trim();
21
+ }
22
+ async function gitSafe(args2, options) {
23
+ try {
24
+ const stdout = await git(args2, options);
25
+ return { stdout, exitCode: 0 };
26
+ } catch (err) {
27
+ return { stdout: err.stdout?.trim() ?? "", exitCode: err.code ?? 1 };
28
+ }
29
+ }
30
+ async function repoRoot() {
31
+ return git(["rev-parse", "--show-toplevel"]);
32
+ }
33
+
34
+ // src/paths.ts
35
+ var _root = null;
36
+ async function root() {
37
+ if (!_root) {
38
+ try {
39
+ _root = await repoRoot();
40
+ } catch {
41
+ console.error("error: git repository not found");
42
+ process.exit(1);
43
+ }
44
+ }
45
+ return _root;
46
+ }
47
+ async function agentnoteDir() {
48
+ return join(await root(), ".git", "agentnote");
49
+ }
50
+ async function sessionFile() {
51
+ return join(await agentnoteDir(), "session");
52
+ }
53
+
54
+ // src/agents/claude-code.ts
55
+ import { readFile, writeFile, mkdir } from "node:fs/promises";
56
+ import { existsSync, globSync } from "node:fs";
57
+ import { join as join2 } from "node:path";
58
+ import { homedir } from "node:os";
59
+ var HOOK_COMMAND = "$(npm bin 2>/dev/null)/agentnote hook 2>/dev/null || agentnote hook 2>/dev/null || npx --yes @wasabeef/agentnote hook";
60
+ var HOOKS_CONFIG = {
61
+ SessionStart: [
62
+ { hooks: [{ type: "command", command: HOOK_COMMAND, async: true }] }
63
+ ],
64
+ Stop: [
65
+ { hooks: [{ type: "command", command: HOOK_COMMAND, async: true }] }
66
+ ],
67
+ UserPromptSubmit: [
68
+ { hooks: [{ type: "command", command: HOOK_COMMAND, async: true }] }
69
+ ],
70
+ PreToolUse: [
71
+ {
72
+ matcher: "Bash",
73
+ hooks: [{ type: "command", if: "Bash(git commit *)", command: HOOK_COMMAND }]
74
+ }
75
+ ],
76
+ PostToolUse: [
77
+ {
78
+ matcher: "Edit|Write|NotebookEdit|Bash",
79
+ hooks: [{ type: "command", command: HOOK_COMMAND, async: true }]
80
+ }
81
+ ]
82
+ };
83
+ var UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
84
+ function isValidSessionId(id) {
85
+ return UUID_PATTERN.test(id);
86
+ }
87
+ function isValidTranscriptPath(p) {
88
+ const claudeBase = join2(homedir(), ".claude");
89
+ return p.startsWith(claudeBase);
90
+ }
91
+ function isGitCommit(cmd) {
92
+ const trimmed = cmd.trim();
93
+ return (trimmed.startsWith("git commit") || trimmed.startsWith("git -c ")) && trimmed.includes("commit") && !trimmed.includes("--amend");
94
+ }
95
+ var claudeCode = {
96
+ name: "claude-code",
97
+ settingsRelPath: ".claude/settings.json",
98
+ async installHooks(repoRoot2) {
99
+ const settingsPath = join2(repoRoot2, this.settingsRelPath);
100
+ const { dirname } = await import("node:path");
101
+ await mkdir(dirname(settingsPath), { recursive: true });
102
+ let settings = {};
103
+ if (existsSync(settingsPath)) {
104
+ try {
105
+ settings = JSON.parse(await readFile(settingsPath, "utf-8"));
106
+ } catch {
107
+ settings = {};
108
+ }
109
+ }
110
+ const hooks = settings.hooks ?? {};
111
+ const raw = JSON.stringify(hooks);
112
+ if (raw.includes("@wasabeef/agentnote")) return;
113
+ for (const [event, entries] of Object.entries(HOOKS_CONFIG)) {
114
+ hooks[event] = [...hooks[event] ?? [], ...entries];
115
+ }
116
+ settings.hooks = hooks;
117
+ await writeFile(settingsPath, JSON.stringify(settings, null, 2) + "\n");
118
+ },
119
+ async removeHooks(repoRoot2) {
120
+ const settingsPath = join2(repoRoot2, this.settingsRelPath);
121
+ if (!existsSync(settingsPath)) return;
122
+ try {
123
+ const settings = JSON.parse(await readFile(settingsPath, "utf-8"));
124
+ if (!settings.hooks) return;
125
+ for (const [event, entries] of Object.entries(settings.hooks)) {
126
+ settings.hooks[event] = entries.filter((e) => {
127
+ const text = JSON.stringify(e);
128
+ return !text.includes("@wasabeef/agentnote");
129
+ });
130
+ if (settings.hooks[event].length === 0) delete settings.hooks[event];
131
+ }
132
+ if (Object.keys(settings.hooks).length === 0) delete settings.hooks;
133
+ await writeFile(settingsPath, JSON.stringify(settings, null, 2) + "\n");
134
+ } catch {
135
+ }
136
+ },
137
+ async isEnabled(repoRoot2) {
138
+ const settingsPath = join2(repoRoot2, this.settingsRelPath);
139
+ if (!existsSync(settingsPath)) return false;
140
+ try {
141
+ const content = await readFile(settingsPath, "utf-8");
142
+ return content.includes("@wasabeef/agentnote");
143
+ } catch {
144
+ return false;
145
+ }
146
+ },
147
+ parseEvent(input) {
148
+ let e;
149
+ try {
150
+ e = JSON.parse(input.raw);
151
+ } catch {
152
+ return null;
153
+ }
154
+ const sid = e.session_id;
155
+ const ts = (/* @__PURE__ */ new Date()).toISOString();
156
+ if (!sid || !isValidSessionId(sid)) return null;
157
+ const tp = e.transcript_path && isValidTranscriptPath(e.transcript_path) ? e.transcript_path : void 0;
158
+ switch (e.hook_event_name) {
159
+ case "SessionStart":
160
+ return { kind: "session_start", sessionId: sid, timestamp: ts, model: e.model, transcriptPath: tp };
161
+ case "Stop":
162
+ return { kind: "stop", sessionId: sid, timestamp: ts, transcriptPath: tp };
163
+ case "UserPromptSubmit":
164
+ return e.prompt ? { kind: "prompt", sessionId: sid, timestamp: ts, prompt: e.prompt } : null;
165
+ case "PreToolUse": {
166
+ const cmd = e.tool_input?.command ?? "";
167
+ if (e.tool_name === "Bash" && isGitCommit(cmd)) {
168
+ return { kind: "pre_commit", sessionId: sid, timestamp: ts, commitCommand: cmd };
169
+ }
170
+ return null;
171
+ }
172
+ case "PostToolUse": {
173
+ const tool = e.tool_name;
174
+ if ((tool === "Edit" || tool === "Write" || tool === "NotebookEdit") && e.tool_input?.file_path) {
175
+ return { kind: "file_change", sessionId: sid, timestamp: ts, tool, file: e.tool_input.file_path };
176
+ }
177
+ if (tool === "Bash" && isGitCommit(e.tool_input?.command ?? "")) {
178
+ return { kind: "post_commit", sessionId: sid, timestamp: ts, transcriptPath: tp };
179
+ }
180
+ return null;
181
+ }
182
+ default:
183
+ return null;
184
+ }
185
+ },
186
+ findTranscript(sessionId) {
187
+ if (!isValidSessionId(sessionId)) return null;
188
+ const claudeDir = join2(homedir(), ".claude", "projects");
189
+ const pattern = join2(claudeDir, "**", "sessions", `${sessionId}.jsonl`);
190
+ const matches = globSync(pattern);
191
+ if (matches.length === 0) return null;
192
+ const match = matches[0];
193
+ return isValidTranscriptPath(match) ? match : null;
194
+ },
195
+ async extractInteractions(transcriptPath) {
196
+ if (!isValidTranscriptPath(transcriptPath) || !existsSync(transcriptPath)) return [];
197
+ try {
198
+ const content = await readFile(transcriptPath, "utf-8");
199
+ const lines = content.trim().split("\n");
200
+ const interactions = [];
201
+ let pendingPrompt = null;
202
+ for (const line of lines) {
203
+ try {
204
+ const entry = JSON.parse(line);
205
+ if (entry.type === "user" && entry.message?.content) {
206
+ for (const block of entry.message.content) {
207
+ if (block.type === "text" && block.text) {
208
+ if (pendingPrompt !== null) {
209
+ interactions.push({ prompt: pendingPrompt, response: null });
210
+ }
211
+ pendingPrompt = block.text;
212
+ }
213
+ }
214
+ }
215
+ if (entry.type === "assistant" && entry.message?.content && pendingPrompt !== null) {
216
+ const texts = [];
217
+ for (const block of entry.message.content) {
218
+ if (block.type === "text" && block.text) texts.push(block.text);
219
+ }
220
+ if (texts.length > 0) {
221
+ interactions.push({ prompt: pendingPrompt, response: texts.join("\n") });
222
+ pendingPrompt = null;
223
+ }
224
+ }
225
+ } catch {
226
+ }
227
+ }
228
+ if (pendingPrompt !== null) {
229
+ interactions.push({ prompt: pendingPrompt, response: null });
230
+ }
231
+ return interactions;
232
+ } catch {
233
+ return [];
234
+ }
235
+ }
236
+ };
237
+
238
+ // src/commands/init.ts
239
+ var WORKFLOW_TEMPLATE = `name: Agentnote
240
+ on:
241
+ pull_request:
242
+ types: [opened, synchronize]
243
+ permissions:
244
+ contents: read
245
+ pull-requests: write
246
+ jobs:
247
+ report:
248
+ runs-on: ubuntu-latest
249
+ steps:
250
+ - uses: actions/checkout@v4
251
+ with:
252
+ fetch-depth: 0
253
+ - uses: wasabeef/agentnote@v1
254
+ `;
255
+ async function init(args2) {
256
+ const skipHooks = args2.includes("--no-hooks");
257
+ const skipAction = args2.includes("--no-action");
258
+ const skipNotes = args2.includes("--no-notes");
259
+ const hooksOnly = args2.includes("--hooks");
260
+ const actionOnly = args2.includes("--action");
261
+ const repoRoot2 = await root();
262
+ const results = [];
263
+ await mkdir2(await agentnoteDir(), { recursive: true });
264
+ if (!skipHooks && !actionOnly) {
265
+ const adapter = claudeCode;
266
+ if (await adapter.isEnabled(repoRoot2)) {
267
+ results.push(" \xB7 hooks already configured");
268
+ } else {
269
+ await adapter.installHooks(repoRoot2);
270
+ results.push(" \u2713 hooks added to .claude/settings.json");
271
+ }
272
+ }
273
+ if (!skipAction && !hooksOnly) {
274
+ const workflowDir = join3(repoRoot2, ".github", "workflows");
275
+ const workflowPath = join3(workflowDir, "agentnote.yml");
276
+ if (existsSync2(workflowPath)) {
277
+ results.push(" \xB7 workflow already exists at .github/workflows/agentnote.yml");
278
+ } else {
279
+ await mkdir2(workflowDir, { recursive: true });
280
+ await writeFile2(workflowPath, WORKFLOW_TEMPLATE);
281
+ results.push(" \u2713 workflow created at .github/workflows/agentnote.yml");
282
+ }
283
+ }
284
+ if (!skipNotes && !hooksOnly && !actionOnly) {
285
+ const { stdout } = await gitSafe([
286
+ "config",
287
+ "--get-all",
288
+ "remote.origin.fetch"
289
+ ]);
290
+ if (stdout.includes("refs/notes/agentnote")) {
291
+ results.push(" \xB7 git already configured to fetch notes");
292
+ } else {
293
+ await gitSafe([
294
+ "config",
295
+ "--add",
296
+ "remote.origin.fetch",
297
+ "+refs/notes/agentnote:refs/notes/agentnote"
298
+ ]);
299
+ results.push(" \u2713 git configured to auto-fetch notes on pull");
300
+ }
301
+ }
302
+ console.log("");
303
+ console.log("agentnote init");
304
+ console.log("");
305
+ for (const line of results) {
306
+ console.log(line);
307
+ }
308
+ const toCommit = [];
309
+ if (!skipHooks && !actionOnly) toCommit.push(".claude/settings.json");
310
+ if (!skipAction && !hooksOnly) {
311
+ const workflowPath = join3(repoRoot2, ".github", "workflows", "agentnote.yml");
312
+ if (existsSync2(workflowPath)) toCommit.push(".github/workflows/agentnote.yml");
313
+ }
314
+ if (toCommit.length > 0) {
315
+ console.log("");
316
+ console.log(" Next: commit and push these files");
317
+ console.log(` git add ${toCommit.join(" ")}`);
318
+ console.log(' git commit -m "chore: enable agentnote session tracking"');
319
+ console.log(" git push");
320
+ }
321
+ console.log("");
322
+ }
323
+
324
+ // src/commands/commit.ts
325
+ import { readFile as readFile3 } from "node:fs/promises";
326
+ import { existsSync as existsSync5 } from "node:fs";
327
+ import { spawn } from "node:child_process";
328
+ import { join as join5 } from "node:path";
329
+
330
+ // src/core/jsonl.ts
331
+ import { readFile as readFile2, appendFile } from "node:fs/promises";
332
+ import { existsSync as existsSync3 } from "node:fs";
333
+ async function readJsonlField(filePath, field) {
334
+ if (!existsSync3(filePath)) return [];
335
+ const content = await readFile2(filePath, "utf-8");
336
+ const seen = /* @__PURE__ */ new Set();
337
+ const values = [];
338
+ for (const line of content.trim().split("\n")) {
339
+ if (!line) continue;
340
+ try {
341
+ const entry = JSON.parse(line);
342
+ const val = entry[field];
343
+ if (val && !seen.has(val)) {
344
+ seen.add(val);
345
+ values.push(val);
346
+ }
347
+ } catch {
348
+ }
349
+ }
350
+ return values;
351
+ }
352
+ async function appendJsonl(filePath, data) {
353
+ await appendFile(filePath, JSON.stringify(data) + "\n");
354
+ }
355
+
356
+ // src/core/storage.ts
357
+ var NOTES_REF = "agentnote";
358
+ async function writeNote(commitSha, data) {
359
+ const body = JSON.stringify(data, null, 2);
360
+ await gitSafe(["notes", `--ref=${NOTES_REF}`, "add", "-f", "-m", body, commitSha]);
361
+ }
362
+ async function readNote(commitSha) {
363
+ const { stdout, exitCode } = await gitSafe([
364
+ "notes",
365
+ `--ref=${NOTES_REF}`,
366
+ "show",
367
+ commitSha
368
+ ]);
369
+ if (exitCode !== 0 || !stdout.trim()) return null;
370
+ try {
371
+ return JSON.parse(stdout);
372
+ } catch {
373
+ return null;
374
+ }
375
+ }
376
+
377
+ // src/core/entry.ts
378
+ var SCHEMA_VERSION = 1;
379
+ var RESPONSE_MAX_LENGTH = 2e3;
380
+ function calcAiRatio(commitFiles, aiFiles) {
381
+ if (commitFiles.length === 0) return 0;
382
+ const aiSet = new Set(aiFiles);
383
+ const matched = commitFiles.filter((f) => aiSet.has(f));
384
+ return Math.round(matched.length / commitFiles.length * 100);
385
+ }
386
+ function truncate(text, maxLen) {
387
+ if (text.length <= maxLen) return text;
388
+ return text.slice(0, maxLen) + "\u2026";
389
+ }
390
+ function buildEntry(opts) {
391
+ return {
392
+ v: SCHEMA_VERSION,
393
+ session_id: opts.sessionId,
394
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
395
+ interactions: opts.interactions.map((i) => ({
396
+ prompt: i.prompt,
397
+ response: i.response ? truncate(i.response, RESPONSE_MAX_LENGTH) : null
398
+ })),
399
+ files_in_commit: opts.commitFiles,
400
+ files_by_ai: opts.aiFiles,
401
+ ai_ratio: calcAiRatio(opts.commitFiles, opts.aiFiles)
402
+ };
403
+ }
404
+
405
+ // src/core/rotate.ts
406
+ import { rename } from "node:fs/promises";
407
+ import { existsSync as existsSync4 } from "node:fs";
408
+ import { join as join4 } from "node:path";
409
+ async function rotateLogs(sessionDir, commitSha, fileNames = ["prompts.jsonl", "changes.jsonl"]) {
410
+ for (const name of fileNames) {
411
+ const src = join4(sessionDir, name);
412
+ if (existsSync4(src)) {
413
+ const base = name.replace(".jsonl", "");
414
+ await rename(
415
+ src,
416
+ join4(sessionDir, `${base}-${commitSha.slice(0, 8)}.jsonl`)
417
+ );
418
+ }
419
+ }
420
+ }
421
+
422
+ // src/commands/commit.ts
423
+ async function commit(args2) {
424
+ const sf = await sessionFile();
425
+ let sessionId = "";
426
+ if (existsSync5(sf)) {
427
+ sessionId = (await readFile3(sf, "utf-8")).trim();
428
+ }
429
+ const gitArgs = ["commit"];
430
+ if (sessionId) {
431
+ gitArgs.push("--trailer", `Agentnote-Session: ${sessionId}`);
432
+ }
433
+ gitArgs.push(...args2);
434
+ const child = spawn("git", gitArgs, {
435
+ stdio: "inherit",
436
+ cwd: process.cwd()
437
+ });
438
+ const exitCode = await new Promise((resolve) => {
439
+ child.on("close", (code) => resolve(code ?? 1));
440
+ });
441
+ if (exitCode !== 0) {
442
+ process.exit(exitCode);
443
+ }
444
+ if (sessionId) {
445
+ try {
446
+ const agentnoteDirPath = await agentnoteDir();
447
+ const sessionDir = join5(agentnoteDirPath, "sessions", sessionId);
448
+ const commitSha = await git(["rev-parse", "HEAD"]);
449
+ let commitFiles = [];
450
+ try {
451
+ const raw = await git([
452
+ "diff-tree",
453
+ "--no-commit-id",
454
+ "--name-only",
455
+ "-r",
456
+ "HEAD"
457
+ ]);
458
+ commitFiles = raw.split("\n").filter(Boolean);
459
+ } catch {
460
+ }
461
+ const aiFiles = await readJsonlField(
462
+ join5(sessionDir, "changes.jsonl"),
463
+ "file"
464
+ );
465
+ const prompts = await readJsonlField(
466
+ join5(sessionDir, "prompts.jsonl"),
467
+ "prompt"
468
+ );
469
+ let interactions;
470
+ const transcriptPathFile = join5(sessionDir, "transcript_path");
471
+ if (existsSync5(transcriptPathFile)) {
472
+ const transcriptPath = (await readFile3(transcriptPathFile, "utf-8")).trim();
473
+ if (transcriptPath) {
474
+ const allInteractions = await claudeCode.extractInteractions(transcriptPath);
475
+ interactions = prompts.length > 0 && allInteractions.length > 0 ? allInteractions.slice(-prompts.length) : prompts.map((p) => ({ prompt: p, response: null }));
476
+ } else {
477
+ interactions = prompts.map((p) => ({ prompt: p, response: null }));
478
+ }
479
+ } else {
480
+ interactions = prompts.map((p) => ({ prompt: p, response: null }));
481
+ }
482
+ const entry = buildEntry({
483
+ sessionId,
484
+ interactions,
485
+ commitFiles,
486
+ aiFiles
487
+ });
488
+ await writeNote(commitSha, entry);
489
+ await rotateLogs(sessionDir, commitSha);
490
+ console.log(
491
+ `agentnote: ${interactions.length} prompts, AI ratio ${entry.ai_ratio}%`
492
+ );
493
+ } catch (err) {
494
+ console.error(`agentnote: warning: ${err.message}`);
495
+ }
496
+ }
497
+ }
498
+
499
+ // src/commands/show.ts
500
+ import { stat } from "node:fs/promises";
501
+ async function show(commitRef) {
502
+ const ref = commitRef ?? "HEAD";
503
+ const commitInfo = await git(["log", "-1", "--format=%h %s", ref]);
504
+ const commitSha = await git(["log", "-1", "--format=%H", ref]);
505
+ const sessionId = (await git([
506
+ "log",
507
+ "-1",
508
+ "--format=%(trailers:key=Agentnote-Session,valueonly)",
509
+ ref
510
+ ])).trim();
511
+ console.log(`commit: ${commitInfo}`);
512
+ if (!sessionId) {
513
+ console.log("session: none (no agentnote data)");
514
+ return;
515
+ }
516
+ console.log(`session: ${sessionId}`);
517
+ const raw = await readNote(commitSha);
518
+ const entry = raw;
519
+ if (entry) {
520
+ console.log();
521
+ const ratioBar = renderRatioBar(entry.ai_ratio);
522
+ console.log(`ai: ${entry.ai_ratio}% ${ratioBar}`);
523
+ console.log(
524
+ `files: ${entry.files_in_commit.length} changed, ${entry.files_by_ai.length} by AI`
525
+ );
526
+ if (entry.files_in_commit.length > 0) {
527
+ console.log();
528
+ for (const file of entry.files_in_commit) {
529
+ const isAi = entry.files_by_ai.includes(file);
530
+ const marker = isAi ? " \u{1F916}" : " \u{1F464}";
531
+ console.log(` ${file}${marker}`);
532
+ }
533
+ }
534
+ const interactions = entry.interactions ?? (entry.prompts ?? []).map((p) => ({
535
+ prompt: p,
536
+ response: null
537
+ }));
538
+ if (interactions.length > 0) {
539
+ console.log();
540
+ console.log(`prompts: ${interactions.length}`);
541
+ for (let i = 0; i < interactions.length; i++) {
542
+ const { prompt, response } = interactions[i];
543
+ console.log();
544
+ console.log(` ${i + 1}. ${truncateLines(prompt, 120)}`);
545
+ if (response) {
546
+ console.log(` \u2192 ${truncateLines(response, 200)}`);
547
+ }
548
+ }
549
+ }
550
+ } else {
551
+ console.log("entry: no agentnote note found for this commit");
552
+ }
553
+ console.log();
554
+ const adapter = claudeCode;
555
+ const transcriptPath = adapter.findTranscript(sessionId);
556
+ if (transcriptPath) {
557
+ const stats = await stat(transcriptPath);
558
+ const sizeKb = (stats.size / 1024).toFixed(1);
559
+ console.log(`transcript: ${transcriptPath} (${sizeKb} KB)`);
560
+ } else {
561
+ console.log("transcript: not found locally");
562
+ }
563
+ }
564
+ function renderRatioBar(ratio) {
565
+ const width = 20;
566
+ const filled = Math.round(ratio / 100 * width);
567
+ const empty = width - filled;
568
+ return `[${"\u2588".repeat(filled)}${"\u2591".repeat(empty)}]`;
569
+ }
570
+ function truncateLines(text, maxLen) {
571
+ const firstLine = text.split("\n")[0];
572
+ if (firstLine.length <= maxLen) return firstLine;
573
+ return firstLine.slice(0, maxLen) + "\u2026";
574
+ }
575
+
576
+ // src/commands/log.ts
577
+ async function log(count = 10) {
578
+ const raw = await git([
579
+ "log",
580
+ `-${count}`,
581
+ "--format=%H %h %s %(trailers:key=Agentnote-Session,valueonly)"
582
+ ]);
583
+ if (!raw) {
584
+ console.log("no commits found");
585
+ return;
586
+ }
587
+ for (const line of raw.split("\n")) {
588
+ if (!line.trim()) continue;
589
+ const parts = line.split(" ");
590
+ const fullSha = parts[0];
591
+ const commitPart = parts[1];
592
+ const sid = parts[2]?.trim();
593
+ if (!fullSha || !commitPart) continue;
594
+ if (!sid) {
595
+ console.log(commitPart);
596
+ continue;
597
+ }
598
+ let ratioStr = "";
599
+ let promptCount = "";
600
+ const note = await readNote(fullSha);
601
+ if (note) {
602
+ const entry = note;
603
+ ratioStr = `${entry.ai_ratio}%`;
604
+ promptCount = `${entry.interactions?.length ?? entry.prompts?.length ?? 0}p`;
605
+ }
606
+ if (ratioStr) {
607
+ console.log(
608
+ `${commitPart} [${sid.slice(0, 8)}\u2026 | \u{1F916}${ratioStr} | ${promptCount}]`
609
+ );
610
+ } else {
611
+ console.log(`${commitPart} [${sid.slice(0, 8)}\u2026]`);
612
+ }
613
+ }
614
+ }
615
+
616
+ // src/commands/status.ts
617
+ import { readFile as readFile4 } from "node:fs/promises";
618
+ import { existsSync as existsSync6 } from "node:fs";
619
+ var VERSION = "0.1.0";
620
+ async function status() {
621
+ console.log(`agentnote v${VERSION}`);
622
+ console.log();
623
+ const repoRoot2 = await root();
624
+ const adapter = claudeCode;
625
+ const hooksActive = await adapter.isEnabled(repoRoot2);
626
+ if (hooksActive) {
627
+ console.log("hooks: active");
628
+ } else {
629
+ console.log("hooks: not configured (run 'agentnote init')");
630
+ }
631
+ const sessionPath = await sessionFile();
632
+ if (existsSync6(sessionPath)) {
633
+ const sid = (await readFile4(sessionPath, "utf-8")).trim();
634
+ console.log(`session: ${sid.slice(0, 8)}\u2026`);
635
+ } else {
636
+ console.log("session: none");
637
+ }
638
+ const { stdout } = await gitSafe([
639
+ "log",
640
+ "-20",
641
+ "--format=%(trailers:key=Agentnote-Session,valueonly)"
642
+ ]);
643
+ const linked = stdout.split("\n").filter((line) => line.trim().length > 0).length;
644
+ console.log(`linked: ${linked}/20 recent commits`);
645
+ }
646
+
647
+ // src/commands/hook.ts
648
+ import { mkdir as mkdir3, readFile as readFile5, writeFile as writeFile3, realpath } from "node:fs/promises";
649
+ import { existsSync as existsSync7 } from "node:fs";
650
+ import { join as join6, relative, isAbsolute } from "node:path";
651
+ async function readStdin() {
652
+ const chunks = [];
653
+ for await (const chunk of process.stdin) {
654
+ chunks.push(chunk);
655
+ }
656
+ return Buffer.concat(chunks).toString("utf-8");
657
+ }
658
+ async function hook() {
659
+ const raw = await readStdin();
660
+ let sync = false;
661
+ try {
662
+ const peek = JSON.parse(raw);
663
+ sync = peek.hook_event_name === "PreToolUse";
664
+ } catch {
665
+ return;
666
+ }
667
+ const adapter = claudeCode;
668
+ const input = { raw, sync };
669
+ const event = adapter.parseEvent(input);
670
+ if (!event) return;
671
+ const agentnoteDirPath = await agentnoteDir();
672
+ const sessionDir = join6(agentnoteDirPath, "sessions", event.sessionId);
673
+ await mkdir3(sessionDir, { recursive: true });
674
+ switch (event.kind) {
675
+ case "session_start": {
676
+ await writeFile3(join6(agentnoteDirPath, "session"), event.sessionId);
677
+ if (event.transcriptPath) {
678
+ await writeFile3(join6(sessionDir, "transcript_path"), event.transcriptPath);
679
+ }
680
+ await appendJsonl(join6(sessionDir, "events.jsonl"), {
681
+ event: "session_start",
682
+ session_id: event.sessionId,
683
+ timestamp: event.timestamp,
684
+ model: event.model ?? null
685
+ });
686
+ break;
687
+ }
688
+ case "stop": {
689
+ await writeFile3(join6(agentnoteDirPath, "session"), event.sessionId);
690
+ if (event.transcriptPath) {
691
+ await writeFile3(join6(sessionDir, "transcript_path"), event.transcriptPath);
692
+ }
693
+ await appendJsonl(join6(sessionDir, "events.jsonl"), {
694
+ event: "stop",
695
+ session_id: event.sessionId,
696
+ timestamp: event.timestamp
697
+ });
698
+ break;
699
+ }
700
+ case "prompt": {
701
+ await appendJsonl(join6(sessionDir, "prompts.jsonl"), {
702
+ event: "prompt",
703
+ timestamp: event.timestamp,
704
+ prompt: event.prompt
705
+ });
706
+ break;
707
+ }
708
+ case "file_change": {
709
+ let filePath = event.file ?? "";
710
+ if (isAbsolute(filePath)) {
711
+ try {
712
+ const rawRoot = (await git(["rev-parse", "--show-toplevel"])).trim();
713
+ const repoRoot2 = await realpath(rawRoot);
714
+ let normalizedFile = filePath;
715
+ if (repoRoot2.startsWith("/private") && !normalizedFile.startsWith("/private")) {
716
+ normalizedFile = "/private" + normalizedFile;
717
+ } else if (!repoRoot2.startsWith("/private") && normalizedFile.startsWith("/private")) {
718
+ normalizedFile = normalizedFile.replace(/^\/private/, "");
719
+ }
720
+ filePath = relative(repoRoot2, normalizedFile);
721
+ } catch {
722
+ }
723
+ }
724
+ await appendJsonl(join6(sessionDir, "changes.jsonl"), {
725
+ event: "file_change",
726
+ timestamp: event.timestamp,
727
+ tool: event.tool,
728
+ file: filePath,
729
+ session_id: event.sessionId
730
+ });
731
+ break;
732
+ }
733
+ case "pre_commit": {
734
+ const cmd = event.commitCommand ?? "";
735
+ if (!cmd.includes("Agentnote-Session") && event.sessionId) {
736
+ process.stdout.write(
737
+ JSON.stringify({
738
+ hookSpecificOutput: {
739
+ hookEventName: "PreToolUse",
740
+ updatedInput: {
741
+ command: `${cmd} --trailer 'Agentnote-Session: ${event.sessionId}'`
742
+ }
743
+ }
744
+ })
745
+ );
746
+ }
747
+ break;
748
+ }
749
+ case "post_commit": {
750
+ try {
751
+ await recordEntry(agentnoteDirPath, event.sessionId, event.transcriptPath);
752
+ } catch {
753
+ }
754
+ break;
755
+ }
756
+ }
757
+ }
758
+ async function recordEntry(agentnoteDirPath, sessionId, eventTranscriptPath) {
759
+ const sessionDir = join6(agentnoteDirPath, "sessions", sessionId);
760
+ const commitSha = await git(["rev-parse", "HEAD"]);
761
+ let commitFiles = [];
762
+ try {
763
+ const raw = await git(["diff-tree", "--no-commit-id", "--name-only", "-r", "HEAD"]);
764
+ commitFiles = raw.split("\n").filter(Boolean);
765
+ } catch {
766
+ }
767
+ const aiFiles = await readJsonlField(join6(sessionDir, "changes.jsonl"), "file");
768
+ const prompts = await readJsonlField(join6(sessionDir, "prompts.jsonl"), "prompt");
769
+ const transcriptPath = eventTranscriptPath ?? await readSavedTranscriptPath(sessionDir);
770
+ const adapter = claudeCode;
771
+ let interactions;
772
+ if (transcriptPath) {
773
+ const allInteractions = await adapter.extractInteractions(transcriptPath);
774
+ interactions = prompts.length > 0 && allInteractions.length > 0 ? allInteractions.slice(-prompts.length) : prompts.map((p) => ({ prompt: p, response: null }));
775
+ } else {
776
+ interactions = prompts.map((p) => ({ prompt: p, response: null }));
777
+ }
778
+ const entry = buildEntry({
779
+ sessionId,
780
+ interactions,
781
+ commitFiles,
782
+ aiFiles
783
+ });
784
+ await writeNote(commitSha, entry);
785
+ await rotateLogs(sessionDir, commitSha);
786
+ }
787
+ async function readSavedTranscriptPath(sessionDir) {
788
+ const saved = join6(sessionDir, "transcript_path");
789
+ if (!existsSync7(saved)) return null;
790
+ const p = (await readFile5(saved, "utf-8")).trim();
791
+ return p || null;
792
+ }
793
+
794
+ // src/commands/pr.ts
795
+ import { execFile as execFile2 } from "node:child_process";
796
+ import { promisify as promisify2 } from "node:util";
797
+ var execFileAsync2 = promisify2(execFile2);
798
+ var MARKER_BEGIN = "<!-- agentnote-begin -->";
799
+ var MARKER_END = "<!-- agentnote-end -->";
800
+ async function collectReport(base) {
801
+ const head = await git(["rev-parse", "--short", "HEAD"]);
802
+ const raw = await git([
803
+ "log",
804
+ "--reverse",
805
+ "--format=%H %h %s",
806
+ `${base}..HEAD`
807
+ ]);
808
+ if (!raw.trim()) return null;
809
+ const commits = [];
810
+ for (const line of raw.trim().split("\n")) {
811
+ const [sha, short, ...msgParts] = line.split(" ");
812
+ const message = msgParts.join(" ");
813
+ const note = await readNote(sha);
814
+ if (!note) {
815
+ commits.push({
816
+ sha,
817
+ short,
818
+ message,
819
+ session_id: null,
820
+ ai_ratio: null,
821
+ prompts_count: 0,
822
+ files_total: 0,
823
+ files_ai: 0,
824
+ files: [],
825
+ interactions: []
826
+ });
827
+ continue;
828
+ }
829
+ const entry = note;
830
+ const interactions = entry.interactions ?? (entry.prompts ?? []).map((p) => ({ prompt: p, response: null }));
831
+ const filesInCommit = entry.files_in_commit ?? [];
832
+ const filesByAi = entry.files_by_ai ?? [];
833
+ commits.push({
834
+ sha,
835
+ short,
836
+ message,
837
+ session_id: entry.session_id ?? null,
838
+ ai_ratio: entry.ai_ratio ?? 0,
839
+ prompts_count: interactions.length,
840
+ files_total: filesInCommit.length,
841
+ files_ai: filesByAi.length,
842
+ files: filesInCommit.map((f) => ({
843
+ path: f,
844
+ by_ai: filesByAi.includes(f)
845
+ })),
846
+ interactions
847
+ });
848
+ }
849
+ const tracked = commits.filter((c) => c.session_id !== null);
850
+ const totalFiles = tracked.reduce((s, c) => s + c.files_total, 0);
851
+ const totalFilesAi = tracked.reduce((s, c) => s + c.files_ai, 0);
852
+ return {
853
+ base,
854
+ head,
855
+ total_commits: commits.length,
856
+ tracked_commits: tracked.length,
857
+ total_prompts: tracked.reduce((s, c) => s + c.prompts_count, 0),
858
+ total_files: totalFiles,
859
+ total_files_ai: totalFilesAi,
860
+ overall_ai_ratio: totalFiles > 0 ? Math.round(totalFilesAi / totalFiles * 100) : 0,
861
+ commits
862
+ };
863
+ }
864
+ function renderMarkdown(report) {
865
+ const lines = [];
866
+ lines.push("## \u{1F916} Agentnote \u2014 AI Session Report");
867
+ lines.push("");
868
+ lines.push(
869
+ `**Overall AI ratio: ${report.overall_ai_ratio}%** (${report.tracked_commits}/${report.total_commits} commits tracked, ${report.total_prompts} prompts)`
870
+ );
871
+ lines.push("");
872
+ lines.push("| Commit | AI | Prompts | Files |");
873
+ lines.push("|---|---|---|---|");
874
+ for (const c of report.commits) {
875
+ if (c.ai_ratio === null) {
876
+ lines.push(`| \`${c.short}\` ${c.message} | \u2014 | \u2014 | \u2014 |`);
877
+ continue;
878
+ }
879
+ const bar = renderBar(c.ai_ratio);
880
+ const fileList = c.files.map((f) => `${basename(f.path)} ${f.by_ai ? "\u{1F916}" : "\u{1F464}"}`).join(", ");
881
+ lines.push(
882
+ `| \`${c.short}\` ${c.message} | ${c.ai_ratio}% ${bar} | ${c.prompts_count} | ${fileList} |`
883
+ );
884
+ }
885
+ lines.push("");
886
+ const withPrompts = report.commits.filter((c) => c.interactions.length > 0);
887
+ if (withPrompts.length > 0) {
888
+ lines.push("<details>");
889
+ lines.push(`<summary>Prompts & Responses (${report.total_prompts} total)</summary>`);
890
+ lines.push("");
891
+ for (const c of withPrompts) {
892
+ lines.push(`**\`${c.short}\`** ${c.message}`);
893
+ lines.push("");
894
+ for (const { prompt, response } of c.interactions) {
895
+ lines.push(`> **Prompt:** ${prompt}`);
896
+ if (response) {
897
+ const truncated = response.length > 500 ? response.slice(0, 500) + "\u2026" : response;
898
+ lines.push(">");
899
+ lines.push(`> **Response:** ${truncated.split("\n").join("\n> ")}`);
900
+ }
901
+ lines.push("");
902
+ }
903
+ }
904
+ lines.push("</details>");
905
+ }
906
+ return lines.join("\n");
907
+ }
908
+ function renderChat(report) {
909
+ const lines = [];
910
+ lines.push("## \u{1F916} Agentnote \u2014 Session Transcript");
911
+ lines.push("");
912
+ lines.push(
913
+ `**Overall AI ratio: ${report.overall_ai_ratio}%** (${report.tracked_commits}/${report.total_commits} commits tracked, ${report.total_prompts} prompts)`
914
+ );
915
+ for (const c of report.commits) {
916
+ lines.push("");
917
+ const ratioLabel = c.ai_ratio === null ? "" : c.ai_ratio === 0 ? "\u{1F464} Human 100% \u2591\u2591\u2591\u2591\u2591" : `AI ${c.ai_ratio}% ${renderBar(c.ai_ratio)}`;
918
+ const aiCount = c.files.filter((f) => f.by_ai).length;
919
+ const humanCount = c.files.length - aiCount;
920
+ const fileSummary = c.files.length > 0 ? ` \xB7 ${c.files.length} files (${aiCount} \u{1F916} ${humanCount} \u{1F464})` : "";
921
+ const summaryExtra = ratioLabel ? ` \u2014 ${ratioLabel}` : "";
922
+ const summaryFiles = fileSummary;
923
+ if (c.interactions.length === 0 && c.ai_ratio === null) {
924
+ lines.push(`<details>`);
925
+ lines.push(`<summary><code>${c.short}</code> ${c.message}</summary>`);
926
+ lines.push("");
927
+ lines.push("*No agentnote data for this commit.*");
928
+ lines.push("");
929
+ lines.push(`</details>`);
930
+ continue;
931
+ }
932
+ lines.push(`<details>`);
933
+ lines.push(`<summary><code>${c.short}</code> ${c.message}${summaryExtra}${summaryFiles}</summary>`);
934
+ lines.push("");
935
+ for (const { prompt, response } of c.interactions) {
936
+ lines.push(`> **\u{1F9D1} Prompt**`);
937
+ lines.push(`> ${prompt.split("\n").join("\n> ")}`);
938
+ lines.push("");
939
+ if (response) {
940
+ lines.push(`**\u{1F916} Response**`);
941
+ lines.push("");
942
+ const truncated = response.length > 800 ? response.slice(0, 800) + "\u2026" : response;
943
+ lines.push(truncated);
944
+ lines.push("");
945
+ }
946
+ }
947
+ if (c.interactions.length > 0 && c.ai_ratio === 0) {
948
+ lines.push("*AI provided guidance, but the code was written by a human.*");
949
+ lines.push("");
950
+ }
951
+ if (c.files.length > 0) {
952
+ lines.push("**Files:**");
953
+ for (const f of c.files) {
954
+ lines.push(`- \`${f.path}\` ${f.by_ai ? "\u{1F916}" : "\u{1F464}"}`);
955
+ }
956
+ lines.push("");
957
+ }
958
+ lines.push(`</details>`);
959
+ }
960
+ lines.push("");
961
+ lines.push("---");
962
+ lines.push("");
963
+ lines.push("<details>");
964
+ lines.push(`<summary>\u{1F4CA} Summary</summary>`);
965
+ lines.push("");
966
+ lines.push(
967
+ `**Overall AI ratio: ${report.overall_ai_ratio}%** (${report.tracked_commits}/${report.total_commits} commits, ${report.total_prompts} prompts)`
968
+ );
969
+ lines.push("");
970
+ lines.push("| Commit | AI | Prompts | Files |");
971
+ lines.push("|---|---|---|---|");
972
+ for (const c of report.commits) {
973
+ if (c.ai_ratio === null) {
974
+ lines.push(`| \`${c.short}\` ${c.message} | \u2014 | \u2014 | \u2014 |`);
975
+ continue;
976
+ }
977
+ const fileList = c.files.map((f) => `${basename(f.path)} ${f.by_ai ? "\u{1F916}" : "\u{1F464}"}`).join(", ");
978
+ lines.push(
979
+ `| \`${c.short}\` ${c.message} | ${c.ai_ratio}% ${renderBar(c.ai_ratio)} | ${c.prompts_count} | ${fileList} |`
980
+ );
981
+ }
982
+ lines.push("");
983
+ lines.push("</details>");
984
+ return lines.join("\n");
985
+ }
986
+ function wrapWithMarkers(content) {
987
+ return `${MARKER_BEGIN}
988
+ ${content}
989
+ ${MARKER_END}`;
990
+ }
991
+ function upsertInDescription(existingBody, section) {
992
+ const marked = wrapWithMarkers(section);
993
+ if (existingBody.includes(MARKER_BEGIN)) {
994
+ const before = existingBody.slice(0, existingBody.indexOf(MARKER_BEGIN));
995
+ const after = existingBody.includes(MARKER_END) ? existingBody.slice(existingBody.indexOf(MARKER_END) + MARKER_END.length) : "";
996
+ return before.trimEnd() + "\n\n" + marked + after;
997
+ }
998
+ return existingBody.trimEnd() + "\n\n" + marked;
999
+ }
1000
+ async function updatePrDescription(prNumber, section) {
1001
+ const { stdout: bodyJson } = await execFileAsync2("gh", [
1002
+ "pr",
1003
+ "view",
1004
+ prNumber,
1005
+ "--json",
1006
+ "body"
1007
+ ], { encoding: "utf-8" });
1008
+ const currentBody = JSON.parse(bodyJson).body ?? "";
1009
+ const newBody = upsertInDescription(currentBody, section);
1010
+ await execFileAsync2("gh", [
1011
+ "pr",
1012
+ "edit",
1013
+ prNumber,
1014
+ "--body",
1015
+ newBody
1016
+ ], { encoding: "utf-8" });
1017
+ }
1018
+ async function pr(args2) {
1019
+ const isJson = args2.includes("--json");
1020
+ const formatIdx = args2.indexOf("--format");
1021
+ const format = formatIdx !== -1 ? args2[formatIdx + 1] : "table";
1022
+ const updateIdx = args2.indexOf("--update");
1023
+ const prNumber = updateIdx !== -1 ? args2[updateIdx + 1] : null;
1024
+ const positional = args2.filter(
1025
+ (a, i) => !a.startsWith("--") && (formatIdx === -1 || i !== formatIdx + 1) && (updateIdx === -1 || i !== updateIdx + 1)
1026
+ );
1027
+ const base = positional[0] ?? await detectBaseBranch();
1028
+ if (!base) {
1029
+ console.error(
1030
+ "error: could not detect base branch. pass it as argument: agentnote pr <base>"
1031
+ );
1032
+ process.exit(1);
1033
+ }
1034
+ const report = await collectReport(base);
1035
+ if (!report) {
1036
+ if (isJson) {
1037
+ console.log(JSON.stringify({ error: "no commits found" }));
1038
+ } else {
1039
+ console.log("no commits found between HEAD and " + base);
1040
+ }
1041
+ return;
1042
+ }
1043
+ let output;
1044
+ if (isJson) {
1045
+ output = JSON.stringify(report, null, 2);
1046
+ } else if (format === "chat") {
1047
+ output = renderChat(report);
1048
+ } else {
1049
+ output = renderMarkdown(report);
1050
+ }
1051
+ if (prNumber) {
1052
+ await updatePrDescription(prNumber, output);
1053
+ console.log(`agentnote: PR #${prNumber} description updated`);
1054
+ } else {
1055
+ console.log(output);
1056
+ }
1057
+ }
1058
+ function renderBar(ratio) {
1059
+ const width = 5;
1060
+ const filled = Math.round(ratio / 100 * width);
1061
+ return "\u2588".repeat(filled) + "\u2591".repeat(width - filled);
1062
+ }
1063
+ function basename(path) {
1064
+ return path.split("/").pop() ?? path;
1065
+ }
1066
+ async function detectBaseBranch() {
1067
+ for (const name of ["main", "master", "develop"]) {
1068
+ const { exitCode } = await gitSafe([
1069
+ "rev-parse",
1070
+ "--verify",
1071
+ `origin/${name}`
1072
+ ]);
1073
+ if (exitCode === 0) return `origin/${name}`;
1074
+ }
1075
+ return null;
1076
+ }
1077
+
1078
+ // src/cli.ts
1079
+ var VERSION2 = "0.1.0";
1080
+ var HELP = `
1081
+ agentnote \u2014 remember why your code changed
1082
+
1083
+ usage:
1084
+ agentnote init [options] set up hooks, workflow, and notes auto-fetch
1085
+ --hooks hooks only
1086
+ --action workflow only
1087
+ --no-action skip workflow
1088
+ agentnote show [commit] show session details for a commit
1089
+ agentnote log [n] list recent commits with session info
1090
+ agentnote pr [base] [options] generate report for a PR
1091
+ --json structured JSON
1092
+ --format chat chat-style transcript
1093
+ --update <pr#> insert into PR description
1094
+ agentnote status show current tracking state
1095
+ agentnote commit [args] git commit with session context (optional)
1096
+ agentnote version print version
1097
+ agentnote help show this help
1098
+ `.trim();
1099
+ var command = process.argv[2];
1100
+ var args = process.argv.slice(3);
1101
+ switch (command) {
1102
+ case "init":
1103
+ await init(args);
1104
+ break;
1105
+ case "commit":
1106
+ await commit(args);
1107
+ break;
1108
+ case "show":
1109
+ await show(args[0]);
1110
+ break;
1111
+ case "log":
1112
+ await log(args[0] ? parseInt(args[0], 10) : 10);
1113
+ break;
1114
+ case "pr":
1115
+ await pr(args);
1116
+ break;
1117
+ case "status":
1118
+ await status();
1119
+ break;
1120
+ case "hook":
1121
+ await hook();
1122
+ break;
1123
+ case "version":
1124
+ case "--version":
1125
+ case "-v":
1126
+ console.log(`agentnote v${VERSION2}`);
1127
+ break;
1128
+ case "help":
1129
+ case "--help":
1130
+ case "-h":
1131
+ case void 0:
1132
+ console.log(HELP);
1133
+ break;
1134
+ default:
1135
+ console.error(`unknown command: ${command}`);
1136
+ console.log(HELP);
1137
+ process.exit(1);
1138
+ }
package/package.json ADDED
@@ -0,0 +1,38 @@
1
+ {
2
+ "name": "@wasabeef/agentnote",
3
+ "version": "0.1.0",
4
+ "description": "Remember why your code changed. Link AI agent sessions to git commits.",
5
+ "type": "module",
6
+ "bin": {
7
+ "agentnote": "./dist/cli.js"
8
+ },
9
+ "scripts": {
10
+ "build": "esbuild src/cli.ts --bundle --platform=node --format=esm --outfile=dist/cli.js --banner:js='#!/usr/bin/env node'",
11
+ "dev": "tsx src/cli.ts",
12
+ "lint": "tsc --noEmit",
13
+ "test": "node --import tsx/esm --test 'src/**/*.test.ts'",
14
+ "test:coverage": "node --import tsx/esm --test --experimental-test-coverage 'src/**/*.test.ts'",
15
+ "prepublishOnly": "npm run build"
16
+ },
17
+ "files": [
18
+ "dist"
19
+ ],
20
+ "keywords": [
21
+ "claude-code",
22
+ "git",
23
+ "session",
24
+ "agentnote",
25
+ "ai",
26
+ "traceability"
27
+ ],
28
+ "license": "MIT",
29
+ "engines": {
30
+ "node": ">=20"
31
+ },
32
+ "devDependencies": {
33
+ "@types/node": "^25.5.0",
34
+ "esbuild": "^0.27.5",
35
+ "tsx": "^4.20.0",
36
+ "typescript": "^6.0.2"
37
+ }
38
+ }