@wasabeef/agentnote 0.1.5 → 0.1.7

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 +1018 -514
  2. package/package.json +9 -4
package/dist/cli.js CHANGED
@@ -1,83 +1,59 @@
1
1
  #!/usr/bin/env node
2
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";
3
+ // src/commands/commit.ts
4
+ import { spawn } from "node:child_process";
5
+ import { existsSync as existsSync4 } from "node:fs";
6
+ import { readFile as readFile4 } from "node:fs/promises";
10
7
 
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
- }
8
+ // src/core/constants.ts
9
+ var TRAILER_KEY = "Agentnote-Session";
10
+ var NOTES_REF = "agentnote";
11
+ var NOTES_REF_FULL = `refs/notes/${NOTES_REF}`;
12
+ var NOTES_FETCH_REFSPEC = `+${NOTES_REF_FULL}:${NOTES_REF_FULL}`;
13
+ var AGENTNOTE_DIR = "agentnote";
14
+ var SESSIONS_DIR = "sessions";
15
+ var PROMPTS_FILE = "prompts.jsonl";
16
+ var CHANGES_FILE = "changes.jsonl";
17
+ var EVENTS_FILE = "events.jsonl";
18
+ var TRANSCRIPT_PATH_FILE = "transcript_path";
19
+ var TURN_FILE = "turn";
20
+ var SESSION_FILE = "session";
21
+ var MAX_COMMITS = 500;
22
+ var BAR_WIDTH_COMPACT = 5;
23
+ var BAR_WIDTH_FULL = 20;
24
+ var TRUNCATE_PROMPT = 120;
25
+ var TRUNCATE_RESPONSE_SHOW = 200;
26
+ var TRUNCATE_RESPONSE_PR = 500;
27
+ var TRUNCATE_RESPONSE_CHAT = 800;
28
+ var ARCHIVE_ID_RE = /^[0-9a-z]{6,}$/;
29
+ var HEARTBEAT_FILE = "heartbeat";
30
+ var PRE_BLOBS_FILE = "pre_blobs.jsonl";
31
+ var COMMITTED_PAIRS_FILE = "committed_pairs.jsonl";
32
+ var EMPTY_BLOB = "e69de29bb2d1d6434b8b29ae775ad8c2e48c5391";
33
+ var SCHEMA_VERSION = 1;
34
+ var DEBUG = !!process.env.AGENTNOTE_DEBUG;
33
35
 
34
- // src/paths.ts
35
- var _root = null;
36
- var _gitDir = null;
37
- async function root() {
38
- if (!_root) {
39
- try {
40
- _root = await repoRoot();
41
- } catch {
42
- console.error("error: git repository not found");
43
- process.exit(1);
44
- }
45
- }
46
- return _root;
47
- }
48
- async function gitDir() {
49
- if (!_gitDir) {
50
- _gitDir = await git(["rev-parse", "--git-dir"]);
51
- if (!_gitDir.startsWith("/")) {
52
- _gitDir = join(await root(), _gitDir);
53
- }
54
- }
55
- return _gitDir;
56
- }
57
- async function agentnoteDir() {
58
- return join(await gitDir(), "agentnote");
59
- }
60
- async function sessionFile() {
61
- return join(await agentnoteDir(), "session");
62
- }
36
+ // src/core/record.ts
37
+ import { existsSync as existsSync3 } from "node:fs";
38
+ import { readdir, readFile as readFile3, unlink, writeFile as writeFile2 } from "node:fs/promises";
39
+ import { tmpdir } from "node:os";
40
+ import { join as join2 } from "node:path";
63
41
 
64
42
  // src/agents/claude-code.ts
65
- import { readFile, writeFile, mkdir } from "node:fs/promises";
66
43
  import { existsSync, readdirSync } from "node:fs";
67
- import { join as join2 } from "node:path";
44
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
68
45
  import { homedir } from "node:os";
46
+ import { join } from "node:path";
69
47
  var HOOK_COMMAND = "npx --yes @wasabeef/agentnote hook";
70
48
  var HOOKS_CONFIG = {
71
- SessionStart: [
72
- { hooks: [{ type: "command", command: HOOK_COMMAND, async: true }] }
73
- ],
74
- Stop: [
75
- { hooks: [{ type: "command", command: HOOK_COMMAND, async: true }] }
76
- ],
77
- UserPromptSubmit: [
78
- { hooks: [{ type: "command", command: HOOK_COMMAND, async: true }] }
79
- ],
49
+ SessionStart: [{ hooks: [{ type: "command", command: HOOK_COMMAND, async: true }] }],
50
+ Stop: [{ hooks: [{ type: "command", command: HOOK_COMMAND, async: true }] }],
51
+ UserPromptSubmit: [{ hooks: [{ type: "command", command: HOOK_COMMAND, async: true }] }],
80
52
  PreToolUse: [
53
+ {
54
+ matcher: "Edit|Write|NotebookEdit",
55
+ hooks: [{ type: "command", command: HOOK_COMMAND }]
56
+ },
81
57
  {
82
58
  matcher: "Bash",
83
59
  hooks: [{ type: "command", if: "Bash(*git commit*)", command: HOOK_COMMAND }]
@@ -95,18 +71,17 @@ function isValidSessionId(id) {
95
71
  return UUID_PATTERN.test(id);
96
72
  }
97
73
  function isValidTranscriptPath(p) {
98
- const claudeBase = join2(homedir(), ".claude");
74
+ const claudeBase = join(homedir(), ".claude");
99
75
  return p.startsWith(claudeBase);
100
76
  }
101
77
  function isGitCommit(cmd) {
102
- const trimmed = cmd.trim();
103
- return (trimmed.startsWith("git commit") || trimmed.startsWith("git -c ")) && trimmed.includes("commit") && !trimmed.includes("--amend");
78
+ return cmd.includes("git commit") && !cmd.includes("--amend");
104
79
  }
105
80
  var claudeCode = {
106
81
  name: "claude-code",
107
82
  settingsRelPath: ".claude/settings.json",
108
83
  async installHooks(repoRoot2) {
109
- const settingsPath = join2(repoRoot2, this.settingsRelPath);
84
+ const settingsPath = join(repoRoot2, this.settingsRelPath);
110
85
  const { dirname } = await import("node:path");
111
86
  await mkdir(dirname(settingsPath), { recursive: true });
112
87
  let settings = {};
@@ -119,15 +94,17 @@ var claudeCode = {
119
94
  }
120
95
  const hooks = settings.hooks ?? {};
121
96
  const raw = JSON.stringify(hooks);
122
- if (raw.includes("@wasabeef/agentnote")) return;
97
+ if (raw.includes("@wasabeef/agentnote") || raw.includes("agentnote hook") || raw.includes("cli.js hook"))
98
+ return;
123
99
  for (const [event, entries] of Object.entries(HOOKS_CONFIG)) {
124
100
  hooks[event] = [...hooks[event] ?? [], ...entries];
125
101
  }
126
102
  settings.hooks = hooks;
127
- await writeFile(settingsPath, JSON.stringify(settings, null, 2) + "\n");
103
+ await writeFile(settingsPath, `${JSON.stringify(settings, null, 2)}
104
+ `);
128
105
  },
129
106
  async removeHooks(repoRoot2) {
130
- const settingsPath = join2(repoRoot2, this.settingsRelPath);
107
+ const settingsPath = join(repoRoot2, this.settingsRelPath);
131
108
  if (!existsSync(settingsPath)) return;
132
109
  try {
133
110
  const settings = JSON.parse(await readFile(settingsPath, "utf-8"));
@@ -135,21 +112,22 @@ var claudeCode = {
135
112
  for (const [event, entries] of Object.entries(settings.hooks)) {
136
113
  settings.hooks[event] = entries.filter((e) => {
137
114
  const text = JSON.stringify(e);
138
- return !text.includes("@wasabeef/agentnote");
115
+ return !text.includes("@wasabeef/agentnote") && !text.includes("agentnote hook") && !text.includes("cli.js hook");
139
116
  });
140
117
  if (settings.hooks[event].length === 0) delete settings.hooks[event];
141
118
  }
142
119
  if (Object.keys(settings.hooks).length === 0) delete settings.hooks;
143
- await writeFile(settingsPath, JSON.stringify(settings, null, 2) + "\n");
120
+ await writeFile(settingsPath, `${JSON.stringify(settings, null, 2)}
121
+ `);
144
122
  } catch {
145
123
  }
146
124
  },
147
125
  async isEnabled(repoRoot2) {
148
- const settingsPath = join2(repoRoot2, this.settingsRelPath);
126
+ const settingsPath = join(repoRoot2, this.settingsRelPath);
149
127
  if (!existsSync(settingsPath)) return false;
150
128
  try {
151
129
  const content = await readFile(settingsPath, "utf-8");
152
- return content.includes("@wasabeef/agentnote");
130
+ return content.includes("@wasabeef/agentnote") || content.includes("agentnote hook") || content.includes("cli.js hook");
153
131
  } catch {
154
132
  return false;
155
133
  }
@@ -167,14 +145,31 @@ var claudeCode = {
167
145
  const tp = e.transcript_path && isValidTranscriptPath(e.transcript_path) ? e.transcript_path : void 0;
168
146
  switch (e.hook_event_name) {
169
147
  case "SessionStart":
170
- return { kind: "session_start", sessionId: sid, timestamp: ts, model: e.model, transcriptPath: tp };
148
+ return {
149
+ kind: "session_start",
150
+ sessionId: sid,
151
+ timestamp: ts,
152
+ model: e.model,
153
+ transcriptPath: tp
154
+ };
171
155
  case "Stop":
172
156
  return { kind: "stop", sessionId: sid, timestamp: ts, transcriptPath: tp };
173
157
  case "UserPromptSubmit":
174
158
  return e.prompt ? { kind: "prompt", sessionId: sid, timestamp: ts, prompt: e.prompt } : null;
175
159
  case "PreToolUse": {
160
+ const tool = e.tool_name;
176
161
  const cmd = e.tool_input?.command ?? "";
177
- if (e.tool_name === "Bash" && isGitCommit(cmd)) {
162
+ if ((tool === "Edit" || tool === "Write" || tool === "NotebookEdit") && e.tool_input?.file_path) {
163
+ return {
164
+ kind: "pre_edit",
165
+ sessionId: sid,
166
+ timestamp: ts,
167
+ tool,
168
+ file: e.tool_input.file_path,
169
+ toolUseId: e.tool_use_id
170
+ };
171
+ }
172
+ if (tool === "Bash" && isGitCommit(cmd)) {
178
173
  return { kind: "pre_commit", sessionId: sid, timestamp: ts, commitCommand: cmd };
179
174
  }
180
175
  return null;
@@ -182,7 +177,14 @@ var claudeCode = {
182
177
  case "PostToolUse": {
183
178
  const tool = e.tool_name;
184
179
  if ((tool === "Edit" || tool === "Write" || tool === "NotebookEdit") && e.tool_input?.file_path) {
185
- return { kind: "file_change", sessionId: sid, timestamp: ts, tool, file: e.tool_input.file_path };
180
+ return {
181
+ kind: "file_change",
182
+ sessionId: sid,
183
+ timestamp: ts,
184
+ tool,
185
+ file: e.tool_input.file_path,
186
+ toolUseId: e.tool_use_id
187
+ };
186
188
  }
187
189
  if (tool === "Bash" && isGitCommit(e.tool_input?.command ?? "")) {
188
190
  return { kind: "post_commit", sessionId: sid, timestamp: ts, transcriptPath: tp };
@@ -195,13 +197,13 @@ var claudeCode = {
195
197
  },
196
198
  findTranscript(sessionId) {
197
199
  if (!isValidSessionId(sessionId)) return null;
198
- const claudeDir = join2(homedir(), ".claude", "projects");
200
+ const claudeDir = join(homedir(), ".claude", "projects");
199
201
  if (!existsSync(claudeDir)) return null;
200
202
  try {
201
203
  for (const project of readdirSync(claudeDir)) {
202
- const sessionsDir = join2(claudeDir, project, "sessions");
204
+ const sessionsDir = join(claudeDir, project, "sessions");
203
205
  if (!existsSync(sessionsDir)) continue;
204
- const candidate = join2(sessionsDir, `${sessionId}.jsonl`);
206
+ const candidate = join(sessionsDir, `${sessionId}.jsonl`);
205
207
  if (existsSync(candidate) && isValidTranscriptPath(candidate)) {
206
208
  return candidate;
207
209
  }
@@ -253,141 +255,174 @@ var claudeCode = {
253
255
  }
254
256
  };
255
257
 
256
- // src/commands/init.ts
257
- var WORKFLOW_TEMPLATE = `name: Agent Note
258
- on:
259
- pull_request:
260
- types: [opened, synchronize]
261
- concurrency:
262
- group: agentnote-\${{ github.event.pull_request.number }}
263
- cancel-in-progress: true
264
- permissions:
265
- contents: read
266
- pull-requests: write
267
- jobs:
268
- report:
269
- runs-on: ubuntu-latest
270
- timeout-minutes: 5
271
- steps:
272
- - uses: actions/checkout@v4
273
- with:
274
- fetch-depth: 0
275
- - uses: wasabeef/agentnote@v0
276
- `;
277
- async function init(args2) {
278
- const skipHooks = args2.includes("--no-hooks");
279
- const skipAction = args2.includes("--no-action");
280
- const skipNotes = args2.includes("--no-notes");
281
- const hooksOnly = args2.includes("--hooks");
282
- const actionOnly = args2.includes("--action");
283
- const repoRoot2 = await root();
284
- const results = [];
285
- await mkdir2(await agentnoteDir(), { recursive: true });
286
- if (!skipHooks && !actionOnly) {
287
- const adapter = claudeCode;
288
- if (await adapter.isEnabled(repoRoot2)) {
289
- results.push(" \xB7 hooks already configured");
290
- } else {
291
- await adapter.installHooks(repoRoot2);
292
- results.push(" \u2713 hooks added to .claude/settings.json");
258
+ // src/git.ts
259
+ import { execFile } from "node:child_process";
260
+ import { promisify } from "node:util";
261
+ var execFileAsync = promisify(execFile);
262
+ async function git(args2, options) {
263
+ const { stdout } = await execFileAsync("git", args2, {
264
+ cwd: options?.cwd,
265
+ encoding: "utf-8"
266
+ });
267
+ return stdout.trim();
268
+ }
269
+ async function gitSafe(args2, options) {
270
+ try {
271
+ const stdout = await git(args2, options);
272
+ return { stdout, exitCode: 0 };
273
+ } catch (err) {
274
+ const e = err;
275
+ return {
276
+ stdout: typeof e.stdout === "string" ? e.stdout.trim() : "",
277
+ exitCode: typeof e.code === "number" ? e.code : 1
278
+ };
279
+ }
280
+ }
281
+ async function repoRoot() {
282
+ return git(["rev-parse", "--show-toplevel"]);
283
+ }
284
+
285
+ // src/core/attribution.ts
286
+ function parseUnifiedHunks(diffOutput) {
287
+ const hunks = [];
288
+ for (const line of diffOutput.split("\n")) {
289
+ const m = line.match(/^@@\s+-(\d+)(?:,(\d+))?\s+\+(\d+)(?:,(\d+))?\s+@@/);
290
+ if (m) {
291
+ hunks.push({
292
+ oldStart: Number(m[1]),
293
+ oldCount: m[2] != null ? Number(m[2]) : 1,
294
+ newStart: Number(m[3]),
295
+ newCount: m[4] != null ? Number(m[4]) : 1
296
+ });
293
297
  }
294
298
  }
295
- if (!skipAction && !hooksOnly) {
296
- const workflowDir = join3(repoRoot2, ".github", "workflows");
297
- const workflowPath = join3(workflowDir, "agentnote.yml");
298
- if (existsSync2(workflowPath)) {
299
- results.push(" \xB7 workflow already exists at .github/workflows/agentnote.yml");
300
- } else {
301
- await mkdir2(workflowDir, { recursive: true });
302
- await writeFile2(workflowPath, WORKFLOW_TEMPLATE);
303
- results.push(" \u2713 workflow created at .github/workflows/agentnote.yml");
299
+ return hunks;
300
+ }
301
+ function expandNewPositions(hunks) {
302
+ const positions = /* @__PURE__ */ new Set();
303
+ for (const h of hunks) {
304
+ for (let i = 0; i < h.newCount; i++) {
305
+ positions.add(h.newStart + i);
304
306
  }
305
307
  }
306
- if (!skipNotes && !hooksOnly && !actionOnly) {
307
- const { stdout } = await gitSafe([
308
- "config",
309
- "--get-all",
310
- "remote.origin.fetch"
311
- ]);
312
- if (stdout.includes("refs/notes/agentnote")) {
313
- results.push(" \xB7 git already configured to fetch notes");
308
+ return positions;
309
+ }
310
+ function countLines(hunks) {
311
+ let added = 0;
312
+ let deleted = 0;
313
+ for (const h of hunks) {
314
+ added += h.newCount;
315
+ deleted += h.oldCount;
316
+ }
317
+ return { added, deleted };
318
+ }
319
+ async function computePositionAttribution(parentBlob, committedBlob, turnPairs) {
320
+ const diff1Output = await gitDiffUnified0(parentBlob, committedBlob);
321
+ const diff1Hunks = parseUnifiedHunks(diff1Output);
322
+ const diff1Added = expandNewPositions(diff1Hunks);
323
+ const { added: totalAddedLines, deleted: deletedLines } = countLines(diff1Hunks);
324
+ if (turnPairs.length === 0 || totalAddedLines === 0) {
325
+ return {
326
+ aiAddedLines: 0,
327
+ humanAddedLines: totalAddedLines,
328
+ totalAddedLines,
329
+ deletedLines
330
+ };
331
+ }
332
+ const aiPositions = /* @__PURE__ */ new Set();
333
+ for (const { preBlob, postBlob } of turnPairs) {
334
+ const diff2Output = await gitDiffUnified0(preBlob, committedBlob);
335
+ const diff2Positions = expandNewPositions(parseUnifiedHunks(diff2Output));
336
+ const diff3Output = await gitDiffUnified0(postBlob, committedBlob);
337
+ const diff3Positions = expandNewPositions(parseUnifiedHunks(diff3Output));
338
+ for (const pos of diff2Positions) {
339
+ if (!diff3Positions.has(pos)) {
340
+ aiPositions.add(pos);
341
+ }
342
+ }
343
+ }
344
+ let aiAddedLines = 0;
345
+ let humanAddedLines = 0;
346
+ for (const pos of diff1Added) {
347
+ if (aiPositions.has(pos)) {
348
+ aiAddedLines++;
314
349
  } else {
315
- await gitSafe([
316
- "config",
317
- "--add",
318
- "remote.origin.fetch",
319
- "+refs/notes/agentnote:refs/notes/agentnote"
320
- ]);
321
- results.push(" \u2713 git configured to auto-fetch notes on pull");
350
+ humanAddedLines++;
322
351
  }
323
352
  }
324
- console.log("");
325
- console.log("agentnote init");
326
- console.log("");
327
- for (const line of results) {
328
- console.log(line);
353
+ return { aiAddedLines, humanAddedLines, totalAddedLines, deletedLines };
354
+ }
355
+ async function gitDiffUnified0(blobA, blobB) {
356
+ if (!blobA || !blobB || blobA === blobB) return "";
357
+ const { stdout, exitCode } = await gitSafe(["diff", "--unified=0", "--no-color", blobA, blobB]);
358
+ if (exitCode !== 0 && exitCode !== 1) {
359
+ throw new Error(`git diff failed with exit code ${exitCode}`);
329
360
  }
330
- const toCommit = [];
331
- if (!skipHooks && !actionOnly) toCommit.push(".claude/settings.json");
332
- if (!skipAction && !hooksOnly) {
333
- const workflowPath = join3(repoRoot2, ".github", "workflows", "agentnote.yml");
334
- if (existsSync2(workflowPath)) toCommit.push(".github/workflows/agentnote.yml");
361
+ return stdout;
362
+ }
363
+
364
+ // src/core/entry.ts
365
+ function calcAiRatio(commitFiles, aiFiles, lineCounts) {
366
+ if (lineCounts && lineCounts.totalAddedLines > 0) {
367
+ return Math.round(lineCounts.aiAddedLines / lineCounts.totalAddedLines * 100);
335
368
  }
336
- if (toCommit.length > 0) {
337
- console.log("");
338
- console.log(" Next: commit and push these files");
339
- console.log(` git add ${toCommit.join(" ")}`);
340
- console.log(' git commit -m "chore: enable agentnote session tracking"');
341
- console.log(" git push");
369
+ if (commitFiles.length === 0) return 0;
370
+ const aiSet = new Set(aiFiles);
371
+ const matched = commitFiles.filter((f) => aiSet.has(f));
372
+ return Math.round(matched.length / commitFiles.length * 100);
373
+ }
374
+ function buildEntry(opts) {
375
+ const entry = {
376
+ v: SCHEMA_VERSION,
377
+ session_id: opts.sessionId,
378
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
379
+ interactions: opts.interactions.map((i) => {
380
+ const base = { prompt: i.prompt, response: i.response };
381
+ if (i.files_touched && i.files_touched.length > 0) {
382
+ base.files_touched = i.files_touched;
383
+ }
384
+ return base;
385
+ }),
386
+ files_in_commit: opts.commitFiles,
387
+ files_by_ai: opts.aiFiles,
388
+ ai_ratio: calcAiRatio(opts.commitFiles, opts.aiFiles, opts.lineCounts)
389
+ };
390
+ if (opts.lineCounts) {
391
+ entry.ai_added_lines = opts.lineCounts.aiAddedLines;
392
+ entry.total_added_lines = opts.lineCounts.totalAddedLines;
393
+ entry.deleted_lines = opts.lineCounts.deletedLines;
342
394
  }
343
- console.log("");
395
+ return entry;
344
396
  }
345
397
 
346
- // src/commands/commit.ts
347
- import { readFile as readFile3 } from "node:fs/promises";
348
- import { existsSync as existsSync5 } from "node:fs";
349
- import { spawn } from "node:child_process";
350
- import { join as join5 } from "node:path";
351
-
352
398
  // src/core/jsonl.ts
353
- import { readFile as readFile2, appendFile } from "node:fs/promises";
354
- import { existsSync as existsSync3 } from "node:fs";
355
- async function readJsonlField(filePath, field) {
356
- if (!existsSync3(filePath)) return [];
399
+ import { existsSync as existsSync2 } from "node:fs";
400
+ import { appendFile, readFile as readFile2 } from "node:fs/promises";
401
+ async function readJsonlEntries(filePath) {
402
+ if (!existsSync2(filePath)) return [];
357
403
  const content = await readFile2(filePath, "utf-8");
358
- const seen = /* @__PURE__ */ new Set();
359
- const values = [];
404
+ const entries = [];
360
405
  for (const line of content.trim().split("\n")) {
361
406
  if (!line) continue;
362
407
  try {
363
- const entry = JSON.parse(line);
364
- const val = entry[field];
365
- if (val && !seen.has(val)) {
366
- seen.add(val);
367
- values.push(val);
368
- }
408
+ entries.push(JSON.parse(line));
369
409
  } catch {
370
410
  }
371
411
  }
372
- return values;
412
+ return entries;
373
413
  }
374
414
  async function appendJsonl(filePath, data) {
375
- await appendFile(filePath, JSON.stringify(data) + "\n");
415
+ await appendFile(filePath, `${JSON.stringify(data)}
416
+ `);
376
417
  }
377
418
 
378
419
  // src/core/storage.ts
379
- var NOTES_REF = "agentnote";
380
420
  async function writeNote(commitSha, data) {
381
421
  const body = JSON.stringify(data, null, 2);
382
422
  await gitSafe(["notes", `--ref=${NOTES_REF}`, "add", "-f", "-m", body, commitSha]);
383
423
  }
384
424
  async function readNote(commitSha) {
385
- const { stdout, exitCode } = await gitSafe([
386
- "notes",
387
- `--ref=${NOTES_REF}`,
388
- "show",
389
- commitSha
390
- ]);
425
+ const { stdout, exitCode } = await gitSafe(["notes", `--ref=${NOTES_REF}`, "show", commitSha]);
391
426
  if (exitCode !== 0 || !stdout.trim()) return null;
392
427
  try {
393
428
  return JSON.parse(stdout);
@@ -396,53 +431,364 @@ async function readNote(commitSha) {
396
431
  }
397
432
  }
398
433
 
399
- // src/core/entry.ts
400
- var SCHEMA_VERSION = 1;
401
- function calcAiRatio(commitFiles, aiFiles) {
402
- if (commitFiles.length === 0) return 0;
403
- const aiSet = new Set(aiFiles);
404
- const matched = commitFiles.filter((f) => aiSet.has(f));
405
- return Math.round(matched.length / commitFiles.length * 100);
434
+ // src/core/record.ts
435
+ async function recordCommitEntry(opts) {
436
+ const sessionDir = join2(opts.agentnoteDirPath, "sessions", opts.sessionId);
437
+ const commitSha = await git(["rev-parse", "HEAD"]);
438
+ let commitFiles = [];
439
+ try {
440
+ const raw = await git(["diff-tree", "--no-commit-id", "--name-only", "-r", "HEAD"]);
441
+ commitFiles = raw.split("\n").filter(Boolean);
442
+ } catch {
443
+ }
444
+ const commitFileSet = new Set(commitFiles);
445
+ const allChangeEntries = await readAllSessionJsonl(sessionDir, CHANGES_FILE);
446
+ const promptEntries = await readAllSessionJsonl(sessionDir, PROMPTS_FILE);
447
+ const allPreBlobEntries = await readAllSessionJsonl(sessionDir, PRE_BLOBS_FILE);
448
+ const preBlobTurnById = /* @__PURE__ */ new Map();
449
+ for (const e of allPreBlobEntries) {
450
+ const id = e.tool_use_id;
451
+ if (id && typeof e.turn === "number") preBlobTurnById.set(id, e.turn);
452
+ }
453
+ for (const entry2 of allChangeEntries) {
454
+ const id = entry2.tool_use_id;
455
+ if (id && preBlobTurnById.has(id)) {
456
+ entry2.turn = preBlobTurnById.get(id);
457
+ }
458
+ }
459
+ const consumedPairs = await readConsumedPairs(sessionDir);
460
+ const changeEntries = allChangeEntries.filter((e) => !consumedPairs.has(consumedKey(e)));
461
+ const preBlobEntriesForTurnFix = allPreBlobEntries.filter(
462
+ (e) => !consumedPairs.has(consumedKey(e))
463
+ );
464
+ const hasTurnData = promptEntries.some((e) => typeof e.turn === "number" && e.turn > 0);
465
+ let aiFiles;
466
+ let prompts;
467
+ let relevantPromptEntries;
468
+ const relevantTurns = /* @__PURE__ */ new Set();
469
+ if (hasTurnData) {
470
+ const aiFileSet = /* @__PURE__ */ new Set();
471
+ for (const e of changeEntries) {
472
+ const f = e.file;
473
+ if (f && commitFileSet.has(f)) aiFileSet.add(f);
474
+ }
475
+ for (const e of preBlobEntriesForTurnFix) {
476
+ const f = e.file;
477
+ if (f && commitFileSet.has(f)) aiFileSet.add(f);
478
+ }
479
+ aiFiles = [...aiFileSet];
480
+ for (const entry2 of changeEntries) {
481
+ const file = entry2.file;
482
+ if (file && commitFileSet.has(file)) {
483
+ relevantTurns.add(typeof entry2.turn === "number" ? entry2.turn : 0);
484
+ }
485
+ }
486
+ for (const entry2 of preBlobEntriesForTurnFix) {
487
+ const file = entry2.file;
488
+ if (file && commitFileSet.has(file)) {
489
+ relevantTurns.add(typeof entry2.turn === "number" ? entry2.turn : 0);
490
+ }
491
+ }
492
+ relevantPromptEntries = promptEntries.filter((e) => {
493
+ const turn = typeof e.turn === "number" ? e.turn : 0;
494
+ return relevantTurns.has(turn);
495
+ });
496
+ prompts = relevantPromptEntries.map((e) => e.prompt);
497
+ } else {
498
+ aiFiles = changeEntries.map((e) => e.file).filter(Boolean);
499
+ prompts = promptEntries.map((e) => e.prompt);
500
+ relevantPromptEntries = promptEntries;
501
+ }
502
+ const transcriptPath = opts.transcriptPath ?? await readSavedTranscriptPath(sessionDir);
503
+ let crossTurnCommit = false;
504
+ if (hasTurnData && relevantTurns.size > 0) {
505
+ const turnFilePath = join2(sessionDir, TURN_FILE);
506
+ let currentTurn = 0;
507
+ if (existsSync3(turnFilePath)) {
508
+ currentTurn = Number.parseInt((await readFile3(turnFilePath, "utf-8")).trim(), 10) || 0;
509
+ }
510
+ const minRelevantTurn = Math.min(...relevantTurns);
511
+ crossTurnCommit = minRelevantTurn < currentTurn;
512
+ }
513
+ let interactions;
514
+ if (transcriptPath && prompts.length > 0 && !crossTurnCommit) {
515
+ const allInteractions = await claudeCode.extractInteractions(transcriptPath);
516
+ interactions = allInteractions.length > 0 ? allInteractions.slice(-prompts.length) : prompts.map((p) => ({ prompt: p, response: null }));
517
+ } else {
518
+ interactions = prompts.map((p) => ({ prompt: p, response: null }));
519
+ }
520
+ if (hasTurnData) {
521
+ attachFilesTouched(changeEntries, relevantPromptEntries, interactions, commitFileSet);
522
+ }
523
+ const lineCounts = await computeLineAttribution({
524
+ sessionDir,
525
+ commitFileSet,
526
+ aiFileSet: new Set(aiFiles),
527
+ relevantTurns,
528
+ hasTurnData,
529
+ changeEntries
530
+ });
531
+ const entry = buildEntry({
532
+ sessionId: opts.sessionId,
533
+ interactions,
534
+ commitFiles,
535
+ aiFiles,
536
+ lineCounts: lineCounts ?? void 0
537
+ });
538
+ await writeNote(commitSha, entry);
539
+ await recordConsumedPairs(sessionDir, changeEntries, commitFileSet);
540
+ return { promptCount: interactions.length, aiRatio: entry.ai_ratio };
406
541
  }
407
- function buildEntry(opts) {
408
- return {
409
- v: SCHEMA_VERSION,
410
- session_id: opts.sessionId,
411
- timestamp: (/* @__PURE__ */ new Date()).toISOString(),
412
- interactions: opts.interactions,
413
- files_in_commit: opts.commitFiles,
414
- files_by_ai: opts.aiFiles,
415
- ai_ratio: calcAiRatio(opts.commitFiles, opts.aiFiles)
416
- };
542
+ function attachFilesTouched(changeEntries, promptEntries, interactions, commitFileSet) {
543
+ const filesByTurn = /* @__PURE__ */ new Map();
544
+ for (const entry of changeEntries) {
545
+ const turn = typeof entry.turn === "number" ? entry.turn : 0;
546
+ const file = entry.file;
547
+ if (!file || !commitFileSet.has(file)) continue;
548
+ if (!filesByTurn.has(turn)) filesByTurn.set(turn, /* @__PURE__ */ new Set());
549
+ filesByTurn.get(turn)?.add(file);
550
+ }
551
+ for (let i = 0; i < interactions.length; i++) {
552
+ const promptEntry = promptEntries[i];
553
+ if (!promptEntry) continue;
554
+ const turn = typeof promptEntry.turn === "number" ? promptEntry.turn : 0;
555
+ const files = filesByTurn.get(turn);
556
+ if (files && files.size > 0) {
557
+ interactions[i].files_touched = [...files];
558
+ }
559
+ }
560
+ }
561
+ async function readAllSessionJsonl(sessionDir, baseFile) {
562
+ const stem = baseFile.slice(0, baseFile.lastIndexOf(".jsonl"));
563
+ const files = await readdir(sessionDir).catch(() => []);
564
+ const matching = files.filter((f) => {
565
+ if (f === baseFile) return true;
566
+ const suffix = f.slice(stem.length + 1, -".jsonl".length);
567
+ return f.startsWith(`${stem}-`) && f.endsWith(".jsonl") && ARCHIVE_ID_RE.test(suffix);
568
+ }).sort((a, b) => {
569
+ const getId = (f) => {
570
+ const s = f.slice(stem.length + 1, -".jsonl".length);
571
+ return s ? parseInt(s, 36) : Infinity;
572
+ };
573
+ return getId(a) - getId(b);
574
+ }).map((f) => join2(sessionDir, f));
575
+ const all = [];
576
+ for (const file of matching) {
577
+ const entries = await readJsonlEntries(file);
578
+ all.push(...entries);
579
+ }
580
+ return all;
581
+ }
582
+ async function readSavedTranscriptPath(sessionDir) {
583
+ const saved = join2(sessionDir, TRANSCRIPT_PATH_FILE);
584
+ if (!existsSync3(saved)) return null;
585
+ const p = (await readFile3(saved, "utf-8")).trim();
586
+ return p || null;
587
+ }
588
+ async function computeLineAttribution(opts) {
589
+ const { sessionDir, commitFileSet, aiFileSet, relevantTurns, hasTurnData, changeEntries } = opts;
590
+ let diffTreeOutput;
591
+ try {
592
+ diffTreeOutput = await git(["diff-tree", "--raw", "--root", "-r", "HEAD"]);
593
+ } catch {
594
+ return null;
595
+ }
596
+ const committedBlobs = parseDiffTreeBlobs(diffTreeOutput);
597
+ if (committedBlobs.size === 0) return null;
598
+ await ensureEmptyBlobInStore();
599
+ const preBlobEntries = await readAllSessionJsonl(sessionDir, PRE_BLOBS_FILE);
600
+ const hasPreBlobData = preBlobEntries.some((e) => e.blob);
601
+ const hasPostBlobData = changeEntries.some((e) => e.blob);
602
+ if (!hasPreBlobData && !hasPostBlobData) return null;
603
+ const preBlobById = /* @__PURE__ */ new Map();
604
+ const preBlobsFallback = /* @__PURE__ */ new Map();
605
+ for (const entry of preBlobEntries) {
606
+ const file = entry.file;
607
+ const turn = typeof entry.turn === "number" ? entry.turn : 0;
608
+ const id = entry.tool_use_id;
609
+ if (!file || !commitFileSet.has(file)) continue;
610
+ if (hasTurnData && !relevantTurns.has(turn)) continue;
611
+ if (id) {
612
+ preBlobById.set(id, { file, blob: entry.blob || "", turn });
613
+ } else {
614
+ if (!preBlobsFallback.has(file)) preBlobsFallback.set(file, []);
615
+ preBlobsFallback.get(file)?.push(entry.blob || "");
616
+ }
617
+ }
618
+ const turnPairsByFile = /* @__PURE__ */ new Map();
619
+ const hadNewFileEditByFile = /* @__PURE__ */ new Map();
620
+ const postBlobsFallback = /* @__PURE__ */ new Map();
621
+ for (const entry of changeEntries) {
622
+ const file = entry.file;
623
+ const turn = typeof entry.turn === "number" ? entry.turn : 0;
624
+ const id = entry.tool_use_id;
625
+ const postBlob = entry.blob || "";
626
+ if (!file || !commitFileSet.has(file) || !postBlob) continue;
627
+ if (id) {
628
+ const pre = preBlobById.get(id);
629
+ if (!pre) continue;
630
+ if (hasTurnData && !relevantTurns.has(pre.turn)) continue;
631
+ if (!pre.blob) {
632
+ hadNewFileEditByFile.set(file, true);
633
+ } else {
634
+ if (!turnPairsByFile.has(file)) turnPairsByFile.set(file, []);
635
+ turnPairsByFile.get(file)?.push({ preBlob: pre.blob, postBlob });
636
+ }
637
+ } else {
638
+ if (hasTurnData && !relevantTurns.has(turn)) continue;
639
+ if (!postBlobsFallback.has(file)) postBlobsFallback.set(file, []);
640
+ postBlobsFallback.get(file)?.push(postBlob);
641
+ }
642
+ }
643
+ for (const [file, postBlobs] of postBlobsFallback) {
644
+ const preBlobs = preBlobsFallback.get(file) ?? [];
645
+ const pairCount = Math.min(preBlobs.length, postBlobs.length);
646
+ for (let i = 0; i < pairCount; i++) {
647
+ const pre = preBlobs[i] || "";
648
+ const post = postBlobs[i] || "";
649
+ if (!pre) {
650
+ hadNewFileEditByFile.set(file, true);
651
+ } else if (post) {
652
+ if (!turnPairsByFile.has(file)) turnPairsByFile.set(file, []);
653
+ turnPairsByFile.get(file)?.push({ preBlob: pre, postBlob: post });
654
+ }
655
+ }
656
+ }
657
+ for (const file of aiFileSet) {
658
+ if (!commitFileSet.has(file)) continue;
659
+ const hasPairs = (turnPairsByFile.get(file) ?? []).length > 0;
660
+ const hasNewFileEdit = hadNewFileEditByFile.get(file) ?? false;
661
+ if (!hasPairs && !hasNewFileEdit) {
662
+ return null;
663
+ }
664
+ }
665
+ let totalAiAdded = 0;
666
+ let totalAdded = 0;
667
+ let totalDeleted = 0;
668
+ for (const file of commitFileSet) {
669
+ const blobs = committedBlobs.get(file);
670
+ if (!blobs) continue;
671
+ const { parentBlob, committedBlob } = blobs;
672
+ const turnPairs = turnPairsByFile.get(file) ?? [];
673
+ const hadNewFileEdit = hadNewFileEditByFile.get(file) ?? false;
674
+ try {
675
+ const result = await computePositionAttribution(parentBlob, committedBlob, turnPairs);
676
+ if (hadNewFileEdit && aiFileSet.has(file) && turnPairs.length === 0) {
677
+ totalAiAdded += result.totalAddedLines;
678
+ } else {
679
+ totalAiAdded += result.aiAddedLines;
680
+ }
681
+ totalAdded += result.totalAddedLines;
682
+ totalDeleted += result.deletedLines;
683
+ } catch {
684
+ }
685
+ }
686
+ return { aiAddedLines: totalAiAdded, totalAddedLines: totalAdded, deletedLines: totalDeleted };
687
+ }
688
+ function parseDiffTreeBlobs(output) {
689
+ const map = /* @__PURE__ */ new Map();
690
+ const ZEROS = "0000000000000000000000000000000000000000";
691
+ for (const line of output.split("\n")) {
692
+ const m = line.match(/^:\d+ \d+ ([0-9a-f]+) ([0-9a-f]+) \w+\t(.+)$/);
693
+ if (!m) continue;
694
+ const parentBlob = m[1] === ZEROS ? EMPTY_BLOB : m[1];
695
+ const committedBlob = m[2] === ZEROS ? EMPTY_BLOB : m[2];
696
+ const paths = m[3];
697
+ const parts = paths.split(" ");
698
+ const file = parts[parts.length - 1];
699
+ map.set(file, { parentBlob, committedBlob });
700
+ }
701
+ return map;
702
+ }
703
+ async function readConsumedPairs(sessionDir) {
704
+ const file = join2(sessionDir, COMMITTED_PAIRS_FILE);
705
+ if (!existsSync3(file)) return /* @__PURE__ */ new Set();
706
+ const entries = await readJsonlEntries(file);
707
+ const set = /* @__PURE__ */ new Set();
708
+ for (const e of entries) {
709
+ if (e.tool_use_id) {
710
+ set.add(`id:${e.tool_use_id}`);
711
+ } else if (e.turn !== void 0 && e.file) {
712
+ set.add(`${e.turn}:${e.file}`);
713
+ }
714
+ }
715
+ return set;
716
+ }
717
+ function consumedKey(entry) {
718
+ if (entry.tool_use_id) return `id:${entry.tool_use_id}`;
719
+ return `${entry.turn}:${entry.file}`;
720
+ }
721
+ async function recordConsumedPairs(sessionDir, changeEntries, commitFileSet) {
722
+ const seen = /* @__PURE__ */ new Set();
723
+ const pairsFile = join2(sessionDir, COMMITTED_PAIRS_FILE);
724
+ for (const entry of changeEntries) {
725
+ const file = entry.file;
726
+ if (!file || !commitFileSet.has(file)) continue;
727
+ const key = consumedKey(entry);
728
+ if (seen.has(key)) continue;
729
+ seen.add(key);
730
+ await appendJsonl(pairsFile, {
731
+ turn: entry.turn,
732
+ file,
733
+ tool_use_id: entry.tool_use_id ?? null
734
+ });
735
+ }
736
+ }
737
+ async function ensureEmptyBlobInStore() {
738
+ const tmp = join2(tmpdir(), `agentnote-empty-${process.pid}.tmp`);
739
+ try {
740
+ await writeFile2(tmp, "");
741
+ await git(["hash-object", "-w", tmp]);
742
+ } catch {
743
+ } finally {
744
+ try {
745
+ await unlink(tmp);
746
+ } catch {
747
+ }
748
+ }
417
749
  }
418
750
 
419
- // src/core/rotate.ts
420
- import { rename } from "node:fs/promises";
421
- import { existsSync as existsSync4 } from "node:fs";
422
- import { join as join4 } from "node:path";
423
- async function rotateLogs(sessionDir, commitSha, fileNames = ["prompts.jsonl", "changes.jsonl"]) {
424
- for (const name of fileNames) {
425
- const src = join4(sessionDir, name);
426
- if (existsSync4(src)) {
427
- const base = name.replace(".jsonl", "");
428
- await rename(
429
- src,
430
- join4(sessionDir, `${base}-${commitSha.slice(0, 8)}.jsonl`)
431
- );
751
+ // src/paths.ts
752
+ import { join as join3 } from "node:path";
753
+ var _root = null;
754
+ var _gitDir = null;
755
+ async function root() {
756
+ if (!_root) {
757
+ try {
758
+ _root = await repoRoot();
759
+ } catch {
760
+ console.error("error: git repository not found");
761
+ process.exit(1);
432
762
  }
433
763
  }
764
+ return _root;
765
+ }
766
+ async function gitDir() {
767
+ if (!_gitDir) {
768
+ _gitDir = await git(["rev-parse", "--git-dir"]);
769
+ if (!_gitDir.startsWith("/")) {
770
+ _gitDir = join3(await root(), _gitDir);
771
+ }
772
+ }
773
+ return _gitDir;
774
+ }
775
+ async function agentnoteDir() {
776
+ return join3(await gitDir(), AGENTNOTE_DIR);
777
+ }
778
+ async function sessionFile() {
779
+ return join3(await agentnoteDir(), SESSION_FILE);
434
780
  }
435
781
 
436
782
  // src/commands/commit.ts
437
783
  async function commit(args2) {
438
784
  const sf = await sessionFile();
439
785
  let sessionId = "";
440
- if (existsSync5(sf)) {
441
- sessionId = (await readFile3(sf, "utf-8")).trim();
786
+ if (existsSync4(sf)) {
787
+ sessionId = (await readFile4(sf, "utf-8")).trim();
442
788
  }
443
789
  const gitArgs = ["commit"];
444
790
  if (sessionId) {
445
- gitArgs.push("--trailer", `Agentnote-Session: ${sessionId}`);
791
+ gitArgs.push("--trailer", `${TRAILER_KEY}: ${sessionId}`);
446
792
  }
447
793
  gitArgs.push(...args2);
448
794
  const child = spawn("git", gitArgs, {
@@ -458,210 +804,58 @@ async function commit(args2) {
458
804
  if (sessionId) {
459
805
  try {
460
806
  const agentnoteDirPath = await agentnoteDir();
461
- const sessionDir = join5(agentnoteDirPath, "sessions", sessionId);
462
- const commitSha = await git(["rev-parse", "HEAD"]);
463
- let commitFiles = [];
464
- try {
465
- const raw = await git([
466
- "diff-tree",
467
- "--no-commit-id",
468
- "--name-only",
469
- "-r",
470
- "HEAD"
471
- ]);
472
- commitFiles = raw.split("\n").filter(Boolean);
473
- } catch {
474
- }
475
- const aiFiles = await readJsonlField(
476
- join5(sessionDir, "changes.jsonl"),
477
- "file"
478
- );
479
- const prompts = await readJsonlField(
480
- join5(sessionDir, "prompts.jsonl"),
481
- "prompt"
482
- );
483
- let interactions;
484
- const transcriptPathFile = join5(sessionDir, "transcript_path");
485
- if (existsSync5(transcriptPathFile)) {
486
- const transcriptPath = (await readFile3(transcriptPathFile, "utf-8")).trim();
487
- if (transcriptPath) {
488
- const allInteractions = await claudeCode.extractInteractions(transcriptPath);
489
- interactions = prompts.length > 0 && allInteractions.length > 0 ? allInteractions.slice(-prompts.length) : prompts.map((p) => ({ prompt: p, response: null }));
490
- } else {
491
- interactions = prompts.map((p) => ({ prompt: p, response: null }));
492
- }
493
- } else {
494
- interactions = prompts.map((p) => ({ prompt: p, response: null }));
495
- }
496
- const entry = buildEntry({
497
- sessionId,
498
- interactions,
499
- commitFiles,
500
- aiFiles
501
- });
502
- await writeNote(commitSha, entry);
503
- await rotateLogs(sessionDir, commitSha);
504
- console.log(
505
- `agentnote: ${interactions.length} prompts, AI ratio ${entry.ai_ratio}%`
506
- );
807
+ const result = await recordCommitEntry({ agentnoteDirPath, sessionId });
808
+ console.log(`agentnote: ${result.promptCount} prompts, AI ratio ${result.aiRatio}%`);
507
809
  } catch (err) {
508
810
  console.error(`agentnote: warning: ${err.message}`);
509
811
  }
510
812
  }
511
813
  }
512
814
 
513
- // src/commands/show.ts
514
- import { stat } from "node:fs/promises";
515
- async function show(commitRef) {
516
- const ref = commitRef ?? "HEAD";
517
- const commitInfo = await git(["log", "-1", "--format=%h %s", ref]);
518
- const commitSha = await git(["log", "-1", "--format=%H", ref]);
519
- const sessionId = (await git([
520
- "log",
521
- "-1",
522
- "--format=%(trailers:key=Agentnote-Session,valueonly)",
523
- ref
524
- ])).trim();
525
- console.log(`commit: ${commitInfo}`);
526
- if (!sessionId) {
527
- console.log("session: none (no agentnote data)");
528
- return;
529
- }
530
- console.log(`session: ${sessionId}`);
531
- const raw = await readNote(commitSha);
532
- const entry = raw;
533
- if (entry) {
534
- console.log();
535
- const ratioBar = renderRatioBar(entry.ai_ratio);
536
- console.log(`ai: ${entry.ai_ratio}% ${ratioBar}`);
537
- console.log(
538
- `files: ${entry.files_in_commit.length} changed, ${entry.files_by_ai.length} by AI`
539
- );
540
- if (entry.files_in_commit.length > 0) {
541
- console.log();
542
- for (const file of entry.files_in_commit) {
543
- const isAi = entry.files_by_ai.includes(file);
544
- const marker = isAi ? " \u{1F916}" : " \u{1F464}";
545
- console.log(` ${file}${marker}`);
546
- }
547
- }
548
- const interactions = entry.interactions ?? (entry.prompts ?? []).map((p) => ({
549
- prompt: p,
550
- response: null
551
- }));
552
- if (interactions.length > 0) {
553
- console.log();
554
- console.log(`prompts: ${interactions.length}`);
555
- for (let i = 0; i < interactions.length; i++) {
556
- const { prompt, response } = interactions[i];
557
- console.log();
558
- console.log(` ${i + 1}. ${truncateLines(prompt, 120)}`);
559
- if (response) {
560
- console.log(` \u2192 ${truncateLines(response, 200)}`);
561
- }
562
- }
815
+ // src/commands/hook.ts
816
+ import { existsSync as existsSync6 } from "node:fs";
817
+ import { mkdir as mkdir2, readFile as readFile5, realpath, writeFile as writeFile3 } from "node:fs/promises";
818
+ import { isAbsolute, join as join5, relative } from "node:path";
819
+
820
+ // src/core/rotate.ts
821
+ import { existsSync as existsSync5 } from "node:fs";
822
+ import { rename } from "node:fs/promises";
823
+ import { join as join4 } from "node:path";
824
+ async function rotateLogs(sessionDir, rotateId, fileNames = [PROMPTS_FILE, CHANGES_FILE]) {
825
+ for (const name of fileNames) {
826
+ const src = join4(sessionDir, name);
827
+ if (existsSync5(src)) {
828
+ const base = name.replace(".jsonl", "");
829
+ await rename(src, join4(sessionDir, `${base}-${rotateId}.jsonl`));
563
830
  }
564
- } else {
565
- console.log("entry: no agentnote note found for this commit");
566
- }
567
- console.log();
568
- const adapter = claudeCode;
569
- const transcriptPath = adapter.findTranscript(sessionId);
570
- if (transcriptPath) {
571
- const stats = await stat(transcriptPath);
572
- const sizeKb = (stats.size / 1024).toFixed(1);
573
- console.log(`transcript: ${transcriptPath} (${sizeKb} KB)`);
574
- } else {
575
- console.log("transcript: not found locally");
576
831
  }
577
832
  }
578
- function renderRatioBar(ratio) {
579
- const width = 20;
580
- const filled = Math.round(ratio / 100 * width);
581
- const empty = width - filled;
582
- return `[${"\u2588".repeat(filled)}${"\u2591".repeat(empty)}]`;
583
- }
584
- function truncateLines(text, maxLen) {
585
- const firstLine = text.split("\n")[0];
586
- if (firstLine.length <= maxLen) return firstLine;
587
- return firstLine.slice(0, maxLen) + "\u2026";
588
- }
589
833
 
590
- // src/commands/log.ts
591
- async function log(count = 10) {
592
- const raw = await git([
593
- "log",
594
- `-${count}`,
595
- "--format=%H %h %s %(trailers:key=Agentnote-Session,valueonly)"
596
- ]);
597
- if (!raw) {
598
- console.log("no commits found");
599
- return;
600
- }
601
- for (const line of raw.split("\n")) {
602
- if (!line.trim()) continue;
603
- const parts = line.split(" ");
604
- const fullSha = parts[0];
605
- const commitPart = parts[1];
606
- const sid = parts[2]?.trim();
607
- if (!fullSha || !commitPart) continue;
608
- if (!sid) {
609
- console.log(commitPart);
610
- continue;
611
- }
612
- let ratioStr = "";
613
- let promptCount = "";
614
- const note = await readNote(fullSha);
615
- if (note) {
616
- const entry = note;
617
- ratioStr = `${entry.ai_ratio}%`;
618
- promptCount = `${entry.interactions?.length ?? entry.prompts?.length ?? 0}p`;
619
- }
620
- if (ratioStr) {
621
- console.log(
622
- `${commitPart} [${sid.slice(0, 8)}\u2026 | \u{1F916}${ratioStr} | ${promptCount}]`
623
- );
624
- } else {
625
- console.log(`${commitPart} [${sid.slice(0, 8)}\u2026]`);
834
+ // src/commands/hook.ts
835
+ async function normalizeToRepoRelative(filePath) {
836
+ if (!isAbsolute(filePath)) return filePath;
837
+ try {
838
+ const rawRoot = (await git(["rev-parse", "--show-toplevel"])).trim();
839
+ const repoRoot2 = await realpath(rawRoot);
840
+ let normalized = filePath;
841
+ if (repoRoot2.startsWith("/private") && !normalized.startsWith("/private")) {
842
+ normalized = `/private${normalized}`;
843
+ } else if (!repoRoot2.startsWith("/private") && normalized.startsWith("/private")) {
844
+ normalized = normalized.replace(/^\/private/, "");
626
845
  }
846
+ return relative(repoRoot2, normalized);
847
+ } catch {
848
+ return filePath;
627
849
  }
628
850
  }
629
-
630
- // src/commands/status.ts
631
- import { readFile as readFile4 } from "node:fs/promises";
632
- import { existsSync as existsSync6 } from "node:fs";
633
- var VERSION = "0.1.0";
634
- async function status() {
635
- console.log(`agentnote v${VERSION}`);
636
- console.log();
637
- const repoRoot2 = await root();
638
- const adapter = claudeCode;
639
- const hooksActive = await adapter.isEnabled(repoRoot2);
640
- if (hooksActive) {
641
- console.log("hooks: active");
642
- } else {
643
- console.log("hooks: not configured (run 'agentnote init')");
644
- }
645
- const sessionPath = await sessionFile();
646
- if (existsSync6(sessionPath)) {
647
- const sid = (await readFile4(sessionPath, "utf-8")).trim();
648
- console.log(`session: ${sid.slice(0, 8)}\u2026`);
649
- } else {
650
- console.log("session: none");
851
+ async function blobHash(absPath) {
852
+ try {
853
+ if (!existsSync6(absPath)) return EMPTY_BLOB;
854
+ return (await git(["hash-object", "-w", absPath])).trim();
855
+ } catch {
856
+ return EMPTY_BLOB;
651
857
  }
652
- const { stdout } = await gitSafe([
653
- "log",
654
- "-20",
655
- "--format=%(trailers:key=Agentnote-Session,valueonly)"
656
- ]);
657
- const linked = stdout.split("\n").filter((line) => line.trim().length > 0).length;
658
- console.log(`linked: ${linked}/20 recent commits`);
659
858
  }
660
-
661
- // src/commands/hook.ts
662
- import { mkdir as mkdir3, readFile as readFile5, writeFile as writeFile3, realpath } from "node:fs/promises";
663
- import { existsSync as existsSync7 } from "node:fs";
664
- import { join as join6, relative, isAbsolute } from "node:path";
665
859
  async function readStdin() {
666
860
  const chunks = [];
667
861
  for await (const chunk of process.stdin) {
@@ -683,28 +877,29 @@ async function hook() {
683
877
  const event = adapter.parseEvent(input);
684
878
  if (!event) return;
685
879
  const agentnoteDirPath = await agentnoteDir();
686
- const sessionDir = join6(agentnoteDirPath, "sessions", event.sessionId);
687
- await mkdir3(sessionDir, { recursive: true });
880
+ const sessionDir = join5(agentnoteDirPath, SESSIONS_DIR, event.sessionId);
881
+ await mkdir2(sessionDir, { recursive: true });
688
882
  switch (event.kind) {
689
883
  case "session_start": {
690
- await writeFile3(join6(agentnoteDirPath, "session"), event.sessionId);
884
+ await writeFile3(join5(agentnoteDirPath, SESSION_FILE), event.sessionId);
691
885
  if (event.transcriptPath) {
692
- await writeFile3(join6(sessionDir, "transcript_path"), event.transcriptPath);
886
+ await writeFile3(join5(sessionDir, TRANSCRIPT_PATH_FILE), event.transcriptPath);
693
887
  }
694
- await appendJsonl(join6(sessionDir, "events.jsonl"), {
888
+ await appendJsonl(join5(sessionDir, EVENTS_FILE), {
695
889
  event: "session_start",
696
890
  session_id: event.sessionId,
697
891
  timestamp: event.timestamp,
698
892
  model: event.model ?? null
699
893
  });
894
+ await writeFile3(join5(sessionDir, HEARTBEAT_FILE), String(Date.now()));
700
895
  break;
701
896
  }
702
897
  case "stop": {
703
- await writeFile3(join6(agentnoteDirPath, "session"), event.sessionId);
898
+ await writeFile3(join5(agentnoteDirPath, SESSION_FILE), event.sessionId);
704
899
  if (event.transcriptPath) {
705
- await writeFile3(join6(sessionDir, "transcript_path"), event.transcriptPath);
900
+ await writeFile3(join5(sessionDir, TRANSCRIPT_PATH_FILE), event.transcriptPath);
706
901
  }
707
- await appendJsonl(join6(sessionDir, "events.jsonl"), {
902
+ await appendJsonl(join5(sessionDir, EVENTS_FILE), {
708
903
  event: "stop",
709
904
  session_id: event.sessionId,
710
905
  timestamp: event.timestamp
@@ -712,47 +907,79 @@ async function hook() {
712
907
  break;
713
908
  }
714
909
  case "prompt": {
715
- await appendJsonl(join6(sessionDir, "prompts.jsonl"), {
910
+ const rotateId = Date.now().toString(36);
911
+ await rotateLogs(sessionDir, rotateId, [PROMPTS_FILE, CHANGES_FILE, PRE_BLOBS_FILE]);
912
+ const turnPath = join5(sessionDir, TURN_FILE);
913
+ let turn = 0;
914
+ if (existsSync6(turnPath)) {
915
+ const raw2 = (await readFile5(turnPath, "utf-8")).trim();
916
+ turn = Number.parseInt(raw2, 10) || 0;
917
+ }
918
+ turn += 1;
919
+ await writeFile3(turnPath, String(turn));
920
+ await appendJsonl(join5(sessionDir, PROMPTS_FILE), {
716
921
  event: "prompt",
717
922
  timestamp: event.timestamp,
718
- prompt: event.prompt
923
+ prompt: event.prompt,
924
+ turn
719
925
  });
926
+ await writeFile3(join5(sessionDir, HEARTBEAT_FILE), String(Date.now()));
720
927
  break;
721
928
  }
722
- case "file_change": {
723
- let filePath = event.file ?? "";
724
- if (isAbsolute(filePath)) {
725
- try {
726
- const rawRoot = (await git(["rev-parse", "--show-toplevel"])).trim();
727
- const repoRoot2 = await realpath(rawRoot);
728
- let normalizedFile = filePath;
729
- if (repoRoot2.startsWith("/private") && !normalizedFile.startsWith("/private")) {
730
- normalizedFile = "/private" + normalizedFile;
731
- } else if (!repoRoot2.startsWith("/private") && normalizedFile.startsWith("/private")) {
732
- normalizedFile = normalizedFile.replace(/^\/private/, "");
733
- }
734
- filePath = relative(repoRoot2, normalizedFile);
735
- } catch {
736
- }
929
+ case "pre_edit": {
930
+ const absPath = event.file ?? "";
931
+ const filePath = await normalizeToRepoRelative(absPath);
932
+ let turn = 0;
933
+ const turnPath = join5(sessionDir, TURN_FILE);
934
+ if (existsSync6(turnPath)) {
935
+ const raw2 = (await readFile5(turnPath, "utf-8")).trim();
936
+ turn = Number.parseInt(raw2, 10) || 0;
937
+ }
938
+ const preBlob = isAbsolute(absPath) ? await blobHash(absPath) : EMPTY_BLOB;
939
+ await appendJsonl(join5(sessionDir, PRE_BLOBS_FILE), {
940
+ event: "pre_blob",
941
+ turn,
942
+ file: filePath,
943
+ blob: preBlob,
944
+ // tool_use_id links this pre-blob to its PostToolUse counterpart,
945
+ // enabling correct pairing even when async hooks fire out of order.
946
+ tool_use_id: event.toolUseId ?? null
947
+ });
948
+ break;
949
+ }
950
+ case "file_change": {
951
+ const absPath = event.file ?? "";
952
+ const filePath = await normalizeToRepoRelative(absPath);
953
+ let turn = 0;
954
+ const turnPath = join5(sessionDir, TURN_FILE);
955
+ if (existsSync6(turnPath)) {
956
+ const raw2 = (await readFile5(turnPath, "utf-8")).trim();
957
+ turn = Number.parseInt(raw2, 10) || 0;
737
958
  }
738
- await appendJsonl(join6(sessionDir, "changes.jsonl"), {
959
+ const postBlob = isAbsolute(absPath) ? await blobHash(absPath) : EMPTY_BLOB;
960
+ await appendJsonl(join5(sessionDir, CHANGES_FILE), {
739
961
  event: "file_change",
740
962
  timestamp: event.timestamp,
741
963
  tool: event.tool,
742
964
  file: filePath,
743
- session_id: event.sessionId
965
+ session_id: event.sessionId,
966
+ turn,
967
+ blob: postBlob,
968
+ // Same tool_use_id as the matching pre_blob entry — used for reliable pairing
969
+ // even when this async hook fires after the next prompt has advanced the turn counter.
970
+ tool_use_id: event.toolUseId ?? null
744
971
  });
745
972
  break;
746
973
  }
747
974
  case "pre_commit": {
748
975
  const cmd = event.commitCommand ?? "";
749
- if (!cmd.includes("Agentnote-Session") && event.sessionId) {
976
+ if (!cmd.includes(TRAILER_KEY) && event.sessionId) {
750
977
  process.stdout.write(
751
978
  JSON.stringify({
752
979
  hookSpecificOutput: {
753
980
  hookEventName: "PreToolUse",
754
981
  updatedInput: {
755
- command: `${cmd} --trailer 'Agentnote-Session: ${event.sessionId}'`
982
+ command: `${cmd} --trailer '${TRAILER_KEY}: ${event.sessionId}'`
756
983
  }
757
984
  }
758
985
  })
@@ -762,47 +989,138 @@ async function hook() {
762
989
  }
763
990
  case "post_commit": {
764
991
  try {
765
- await recordEntry(agentnoteDirPath, event.sessionId, event.transcriptPath);
992
+ await recordCommitEntry({
993
+ agentnoteDirPath,
994
+ sessionId: event.sessionId,
995
+ transcriptPath: event.transcriptPath
996
+ });
766
997
  } catch {
767
998
  }
768
999
  break;
769
1000
  }
770
1001
  }
771
1002
  }
772
- async function recordEntry(agentnoteDirPath, sessionId, eventTranscriptPath) {
773
- const sessionDir = join6(agentnoteDirPath, "sessions", sessionId);
774
- const commitSha = await git(["rev-parse", "HEAD"]);
775
- let commitFiles = [];
776
- try {
777
- const raw = await git(["diff-tree", "--no-commit-id", "--name-only", "-r", "HEAD"]);
778
- commitFiles = raw.split("\n").filter(Boolean);
779
- } catch {
1003
+
1004
+ // src/commands/init.ts
1005
+ import { existsSync as existsSync7 } from "node:fs";
1006
+ import { mkdir as mkdir3, writeFile as writeFile4 } from "node:fs/promises";
1007
+ import { join as join6 } from "node:path";
1008
+ var WORKFLOW_TEMPLATE = `name: Agent Note
1009
+ on:
1010
+ pull_request:
1011
+ types: [opened, synchronize]
1012
+ concurrency:
1013
+ group: agentnote-\${{ github.event.pull_request.number }}
1014
+ cancel-in-progress: true
1015
+ permissions:
1016
+ contents: read
1017
+ pull-requests: write
1018
+ jobs:
1019
+ report:
1020
+ runs-on: ubuntu-latest
1021
+ timeout-minutes: 5
1022
+ steps:
1023
+ - uses: actions/checkout@v4
1024
+ with:
1025
+ fetch-depth: 0
1026
+ - uses: wasabeef/agentnote@v0
1027
+ `;
1028
+ async function init(args2) {
1029
+ const skipHooks = args2.includes("--no-hooks");
1030
+ const skipAction = args2.includes("--no-action");
1031
+ const skipNotes = args2.includes("--no-notes");
1032
+ const hooksOnly = args2.includes("--hooks");
1033
+ const actionOnly = args2.includes("--action");
1034
+ const repoRoot2 = await root();
1035
+ const results = [];
1036
+ await mkdir3(await agentnoteDir(), { recursive: true });
1037
+ if (!skipHooks && !actionOnly) {
1038
+ const adapter = claudeCode;
1039
+ if (await adapter.isEnabled(repoRoot2)) {
1040
+ results.push(" \xB7 hooks already configured");
1041
+ } else {
1042
+ await adapter.installHooks(repoRoot2);
1043
+ results.push(" \u2713 hooks added to .claude/settings.json");
1044
+ }
780
1045
  }
781
- const aiFiles = await readJsonlField(join6(sessionDir, "changes.jsonl"), "file");
782
- const prompts = await readJsonlField(join6(sessionDir, "prompts.jsonl"), "prompt");
783
- const transcriptPath = eventTranscriptPath ?? await readSavedTranscriptPath(sessionDir);
784
- const adapter = claudeCode;
785
- let interactions;
786
- if (transcriptPath) {
787
- const allInteractions = await adapter.extractInteractions(transcriptPath);
788
- interactions = prompts.length > 0 && allInteractions.length > 0 ? allInteractions.slice(-prompts.length) : prompts.map((p) => ({ prompt: p, response: null }));
789
- } else {
790
- interactions = prompts.map((p) => ({ prompt: p, response: null }));
1046
+ if (!skipAction && !hooksOnly) {
1047
+ const workflowDir = join6(repoRoot2, ".github", "workflows");
1048
+ const workflowPath = join6(workflowDir, "agentnote.yml");
1049
+ if (existsSync7(workflowPath)) {
1050
+ results.push(" \xB7 workflow already exists at .github/workflows/agentnote.yml");
1051
+ } else {
1052
+ await mkdir3(workflowDir, { recursive: true });
1053
+ await writeFile4(workflowPath, WORKFLOW_TEMPLATE);
1054
+ results.push(" \u2713 workflow created at .github/workflows/agentnote.yml");
1055
+ }
791
1056
  }
792
- const entry = buildEntry({
793
- sessionId,
794
- interactions,
795
- commitFiles,
796
- aiFiles
797
- });
798
- await writeNote(commitSha, entry);
799
- await rotateLogs(sessionDir, commitSha);
1057
+ if (!skipNotes && !hooksOnly && !actionOnly) {
1058
+ const { stdout } = await gitSafe(["config", "--get-all", "remote.origin.fetch"]);
1059
+ if (stdout.includes(NOTES_REF_FULL)) {
1060
+ results.push(" \xB7 git already configured to fetch notes");
1061
+ } else {
1062
+ await gitSafe(["config", "--add", "remote.origin.fetch", NOTES_FETCH_REFSPEC]);
1063
+ results.push(" \u2713 git configured to auto-fetch notes on pull");
1064
+ }
1065
+ }
1066
+ console.log("");
1067
+ console.log("agentnote init");
1068
+ console.log("");
1069
+ for (const line of results) {
1070
+ console.log(line);
1071
+ }
1072
+ const toCommit = [];
1073
+ if (!skipHooks && !actionOnly) toCommit.push(".claude/settings.json");
1074
+ if (!skipAction && !hooksOnly) {
1075
+ const workflowPath = join6(repoRoot2, ".github", "workflows", "agentnote.yml");
1076
+ if (existsSync7(workflowPath)) toCommit.push(".github/workflows/agentnote.yml");
1077
+ }
1078
+ if (toCommit.length > 0) {
1079
+ console.log("");
1080
+ console.log(" Next: commit and push these files");
1081
+ console.log(` git add ${toCommit.join(" ")}`);
1082
+ console.log(' git commit -m "chore: enable agentnote session tracking"');
1083
+ console.log(" git push");
1084
+ }
1085
+ console.log("");
800
1086
  }
801
- async function readSavedTranscriptPath(sessionDir) {
802
- const saved = join6(sessionDir, "transcript_path");
803
- if (!existsSync7(saved)) return null;
804
- const p = (await readFile5(saved, "utf-8")).trim();
805
- return p || null;
1087
+
1088
+ // src/commands/log.ts
1089
+ async function log(count = 10) {
1090
+ const raw = await git([
1091
+ "log",
1092
+ `-${count}`,
1093
+ `--format=%H %h %s %(trailers:key=${TRAILER_KEY},valueonly)`
1094
+ ]);
1095
+ if (!raw) {
1096
+ console.log("no commits found");
1097
+ return;
1098
+ }
1099
+ for (const line of raw.split("\n")) {
1100
+ if (!line.trim()) continue;
1101
+ const parts = line.split(" ");
1102
+ const fullSha = parts[0];
1103
+ const commitPart = parts[1];
1104
+ const sid = parts[2]?.trim();
1105
+ if (!fullSha || !commitPart) continue;
1106
+ if (!sid) {
1107
+ console.log(commitPart);
1108
+ continue;
1109
+ }
1110
+ let ratioStr = "";
1111
+ let promptCount = "";
1112
+ const note = await readNote(fullSha);
1113
+ if (note) {
1114
+ const entry = note;
1115
+ ratioStr = `${entry.ai_ratio}%`;
1116
+ promptCount = `${entry.interactions?.length ?? entry.prompts?.length ?? 0}p`;
1117
+ }
1118
+ if (ratioStr) {
1119
+ console.log(`${commitPart} [${sid.slice(0, 8)}\u2026 | \u{1F916}${ratioStr} | ${promptCount}]`);
1120
+ } else {
1121
+ console.log(`${commitPart} [${sid.slice(0, 8)}\u2026]`);
1122
+ }
1123
+ }
806
1124
  }
807
1125
 
808
1126
  // src/commands/pr.ts
@@ -813,12 +1131,7 @@ var MARKER_BEGIN = "<!-- agentnote-begin -->";
813
1131
  var MARKER_END = "<!-- agentnote-end -->";
814
1132
  async function collectReport(base) {
815
1133
  const head = await git(["rev-parse", "--short", "HEAD"]);
816
- const raw = await git([
817
- "log",
818
- "--reverse",
819
- "--format=%H %h %s",
820
- `${base}..HEAD`
821
- ]);
1134
+ const raw = await git(["log", "--reverse", "--format=%H %h %s", `${base}..HEAD`]);
822
1135
  if (!raw.trim()) return null;
823
1136
  const commits = [];
824
1137
  for (const line of raw.trim().split("\n")) {
@@ -836,7 +1149,9 @@ async function collectReport(base) {
836
1149
  files_total: 0,
837
1150
  files_ai: 0,
838
1151
  files: [],
839
- interactions: []
1152
+ interactions: [],
1153
+ ai_added_lines: null,
1154
+ total_added_lines: null
840
1155
  });
841
1156
  continue;
842
1157
  }
@@ -857,12 +1172,23 @@ async function collectReport(base) {
857
1172
  path: f,
858
1173
  by_ai: filesByAi.includes(f)
859
1174
  })),
860
- interactions
1175
+ interactions,
1176
+ ai_added_lines: entry.ai_added_lines ?? null,
1177
+ total_added_lines: entry.total_added_lines ?? null
861
1178
  });
862
1179
  }
863
1180
  const tracked = commits.filter((c) => c.session_id !== null);
864
1181
  const totalFiles = tracked.reduce((s, c) => s + c.files_total, 0);
865
1182
  const totalFilesAi = tracked.reduce((s, c) => s + c.files_ai, 0);
1183
+ const allHaveLineData = tracked.every((c) => c.total_added_lines !== null);
1184
+ let overallAiRatio;
1185
+ if (allHaveLineData) {
1186
+ const totalAiLines = tracked.reduce((s, c) => s + (c.ai_added_lines ?? 0), 0);
1187
+ const totalAllLines = tracked.reduce((s, c) => s + (c.total_added_lines ?? 0), 0);
1188
+ overallAiRatio = totalAllLines > 0 ? Math.round(totalAiLines / totalAllLines * 100) : 0;
1189
+ } else {
1190
+ overallAiRatio = totalFiles > 0 ? Math.round(totalFilesAi / totalFiles * 100) : 0;
1191
+ }
866
1192
  return {
867
1193
  base,
868
1194
  head,
@@ -871,7 +1197,7 @@ async function collectReport(base) {
871
1197
  total_prompts: tracked.reduce((s, c) => s + c.prompts_count, 0),
872
1198
  total_files: totalFiles,
873
1199
  total_files_ai: totalFilesAi,
874
- overall_ai_ratio: totalFiles > 0 ? Math.round(totalFilesAi / totalFiles * 100) : 0,
1200
+ overall_ai_ratio: overallAiRatio,
875
1201
  commits
876
1202
  };
877
1203
  }
@@ -908,7 +1234,7 @@ function renderMarkdown(report) {
908
1234
  for (const { prompt, response } of c.interactions) {
909
1235
  lines.push(`> **Prompt:** ${prompt}`);
910
1236
  if (response) {
911
- const truncated = response.length > 500 ? response.slice(0, 500) + "\u2026" : response;
1237
+ const truncated = response.length > TRUNCATE_RESPONSE_PR ? `${response.slice(0, TRUNCATE_RESPONSE_PR)}\u2026` : response;
912
1238
  lines.push(">");
913
1239
  lines.push(`> **Response:** ${truncated.split("\n").join("\n> ")}`);
914
1240
  }
@@ -944,7 +1270,9 @@ function renderChat(report) {
944
1270
  continue;
945
1271
  }
946
1272
  lines.push(`<details>`);
947
- lines.push(`<summary><code>${c.short}</code> ${c.message}${summaryExtra}${summaryFiles}</summary>`);
1273
+ lines.push(
1274
+ `<summary><code>${c.short}</code> ${c.message}${summaryExtra}${summaryFiles}</summary>`
1275
+ );
948
1276
  lines.push("");
949
1277
  for (const { prompt, response } of c.interactions) {
950
1278
  lines.push(`> **\u{1F9D1} Prompt**`);
@@ -953,7 +1281,7 @@ function renderChat(report) {
953
1281
  if (response) {
954
1282
  lines.push(`**\u{1F916} Response**`);
955
1283
  lines.push("");
956
- const truncated = response.length > 800 ? response.slice(0, 800) + "\u2026" : response;
1284
+ const truncated = response.length > TRUNCATE_RESPONSE_CHAT ? `${response.slice(0, TRUNCATE_RESPONSE_CHAT)}\u2026` : response;
957
1285
  lines.push(truncated);
958
1286
  lines.push("");
959
1287
  }
@@ -1007,27 +1335,23 @@ function upsertInDescription(existingBody, section) {
1007
1335
  if (existingBody.includes(MARKER_BEGIN)) {
1008
1336
  const before = existingBody.slice(0, existingBody.indexOf(MARKER_BEGIN));
1009
1337
  const after = existingBody.includes(MARKER_END) ? existingBody.slice(existingBody.indexOf(MARKER_END) + MARKER_END.length) : "";
1010
- return before.trimEnd() + "\n\n" + marked + after;
1338
+ return `${before.trimEnd()}
1339
+
1340
+ ${marked}${after}`;
1011
1341
  }
1012
- return existingBody.trimEnd() + "\n\n" + marked;
1342
+ return `${existingBody.trimEnd()}
1343
+
1344
+ ${marked}`;
1013
1345
  }
1014
1346
  async function updatePrDescription(prNumber, section) {
1015
- const { stdout: bodyJson } = await execFileAsync2("gh", [
1016
- "pr",
1017
- "view",
1018
- prNumber,
1019
- "--json",
1020
- "body"
1021
- ], { encoding: "utf-8" });
1347
+ const { stdout: bodyJson } = await execFileAsync2(
1348
+ "gh",
1349
+ ["pr", "view", prNumber, "--json", "body"],
1350
+ { encoding: "utf-8" }
1351
+ );
1022
1352
  const currentBody = JSON.parse(bodyJson).body ?? "";
1023
1353
  const newBody = upsertInDescription(currentBody, section);
1024
- await execFileAsync2("gh", [
1025
- "pr",
1026
- "edit",
1027
- prNumber,
1028
- "--body",
1029
- newBody
1030
- ], { encoding: "utf-8" });
1354
+ await execFileAsync2("gh", ["pr", "edit", prNumber, "--body", newBody], { encoding: "utf-8" });
1031
1355
  }
1032
1356
  async function pr(args2) {
1033
1357
  const isJson = args2.includes("--json");
@@ -1040,9 +1364,7 @@ async function pr(args2) {
1040
1364
  );
1041
1365
  const base = positional[0] ?? await detectBaseBranch();
1042
1366
  if (!base) {
1043
- console.error(
1044
- "error: could not detect base branch. pass it as argument: agentnote pr <base>"
1045
- );
1367
+ console.error("error: could not detect base branch. pass it as argument: agentnote pr <base>");
1046
1368
  process.exit(1);
1047
1369
  }
1048
1370
  const report = await collectReport(base);
@@ -1050,7 +1372,7 @@ async function pr(args2) {
1050
1372
  if (isJson) {
1051
1373
  console.log(JSON.stringify({ error: "no commits found" }));
1052
1374
  } else {
1053
- console.log("no commits found between HEAD and " + base);
1375
+ console.log(`no commits found between HEAD and ${base}`);
1054
1376
  }
1055
1377
  return;
1056
1378
  }
@@ -1070,7 +1392,7 @@ async function pr(args2) {
1070
1392
  }
1071
1393
  }
1072
1394
  function renderBar(ratio) {
1073
- const width = 5;
1395
+ const width = BAR_WIDTH_COMPACT;
1074
1396
  const filled = Math.round(ratio / 100 * width);
1075
1397
  return "\u2588".repeat(filled) + "\u2591".repeat(width - filled);
1076
1398
  }
@@ -1079,20 +1401,199 @@ function basename(path) {
1079
1401
  }
1080
1402
  async function detectBaseBranch() {
1081
1403
  for (const name of ["main", "master", "develop"]) {
1082
- const { exitCode } = await gitSafe([
1083
- "rev-parse",
1084
- "--verify",
1085
- `origin/${name}`
1086
- ]);
1404
+ const { exitCode } = await gitSafe(["rev-parse", "--verify", `origin/${name}`]);
1087
1405
  if (exitCode === 0) return `origin/${name}`;
1088
1406
  }
1089
1407
  return null;
1090
1408
  }
1091
1409
 
1410
+ // src/commands/session.ts
1411
+ async function session(sessionId) {
1412
+ if (!sessionId) {
1413
+ console.error("usage: agentnote session <session-id>");
1414
+ process.exit(1);
1415
+ }
1416
+ const raw = await git([
1417
+ "log",
1418
+ "--all",
1419
+ `--max-count=${MAX_COMMITS}`,
1420
+ `--format=%H %h %s %(trailers:key=${TRAILER_KEY},valueonly)`
1421
+ ]);
1422
+ if (!raw) {
1423
+ console.log("no commits found");
1424
+ return;
1425
+ }
1426
+ const matches = [];
1427
+ for (const line of raw.split("\n")) {
1428
+ if (!line.trim()) continue;
1429
+ const parts = line.split(" ");
1430
+ const fullSha = parts[0];
1431
+ const shortInfo = parts[1];
1432
+ const trailer = parts[2]?.trim();
1433
+ if (!fullSha || !shortInfo) continue;
1434
+ if (trailer === sessionId) {
1435
+ const note = await readNote(fullSha);
1436
+ const entry = note;
1437
+ matches.push({ sha: fullSha, shortInfo, entry });
1438
+ continue;
1439
+ }
1440
+ if (!trailer) {
1441
+ const note = await readNote(fullSha);
1442
+ if (note && note.session_id === sessionId) {
1443
+ const entry = note;
1444
+ matches.push({ sha: fullSha, shortInfo, entry });
1445
+ }
1446
+ }
1447
+ }
1448
+ if (matches.length === 0) {
1449
+ console.log(`no commits found for session ${sessionId}`);
1450
+ return;
1451
+ }
1452
+ matches.reverse();
1453
+ console.log(`Session: ${sessionId}`);
1454
+ console.log(`Commits: ${matches.length}`);
1455
+ console.log();
1456
+ let totalPrompts = 0;
1457
+ let totalAiLines = 0;
1458
+ let totalAllLines = 0;
1459
+ let totalRatio = 0;
1460
+ let ratioCount = 0;
1461
+ for (const m of matches) {
1462
+ let suffix = "";
1463
+ if (m.entry) {
1464
+ const promptCount = m.entry.interactions?.length ?? m.entry.prompts?.length ?? 0;
1465
+ totalPrompts += promptCount;
1466
+ if (m.entry.ai_added_lines !== void 0 && m.entry.total_added_lines !== void 0) {
1467
+ totalAiLines += m.entry.ai_added_lines;
1468
+ totalAllLines += m.entry.total_added_lines;
1469
+ } else {
1470
+ totalRatio += m.entry.ai_ratio;
1471
+ ratioCount++;
1472
+ }
1473
+ suffix = ` [\u{1F916}${m.entry.ai_ratio}% | ${promptCount}p]`;
1474
+ }
1475
+ console.log(`${m.shortInfo}${suffix}`);
1476
+ }
1477
+ console.log();
1478
+ const displayRatio = totalAllLines > 0 ? Math.round(totalAiLines / totalAllLines * 100) : ratioCount > 0 ? Math.round(totalRatio / ratioCount) : null;
1479
+ if (displayRatio !== null) {
1480
+ const lineDetail = totalAllLines > 0 ? ` (${totalAiLines}/${totalAllLines} lines)` : "";
1481
+ console.log(`Total: ${totalPrompts} prompts, AI ratio ${displayRatio}%${lineDetail}`);
1482
+ }
1483
+ }
1484
+
1485
+ // src/commands/show.ts
1486
+ import { stat } from "node:fs/promises";
1487
+ async function show(commitRef) {
1488
+ const ref = commitRef ?? "HEAD";
1489
+ const commitInfo = await git(["log", "-1", "--format=%h %s", ref]);
1490
+ const commitSha = await git(["log", "-1", "--format=%H", ref]);
1491
+ const sessionId = (await git(["log", "-1", `--format=%(trailers:key=${TRAILER_KEY},valueonly)`, ref])).trim();
1492
+ console.log(`commit: ${commitInfo}`);
1493
+ if (!sessionId) {
1494
+ console.log("session: none (no agentnote data)");
1495
+ return;
1496
+ }
1497
+ console.log(`session: ${sessionId}`);
1498
+ const raw = await readNote(commitSha);
1499
+ const entry = raw;
1500
+ if (entry) {
1501
+ console.log();
1502
+ const ratioBar = renderRatioBar(entry.ai_ratio);
1503
+ const lineDetail = entry.ai_added_lines !== void 0 && entry.total_added_lines !== void 0 && entry.total_added_lines > 0 ? ` (${entry.ai_added_lines}/${entry.total_added_lines} lines)` : "";
1504
+ console.log(`ai: ${entry.ai_ratio}%${lineDetail} ${ratioBar}`);
1505
+ console.log(
1506
+ `files: ${entry.files_in_commit.length} changed, ${entry.files_by_ai.length} by AI`
1507
+ );
1508
+ if (entry.files_in_commit.length > 0) {
1509
+ console.log();
1510
+ for (const file of entry.files_in_commit) {
1511
+ const isAi = entry.files_by_ai.includes(file);
1512
+ const marker = isAi ? " \u{1F916}" : " \u{1F464}";
1513
+ console.log(` ${file}${marker}`);
1514
+ }
1515
+ }
1516
+ const legacy = entry;
1517
+ const interactions = entry.interactions ?? (legacy.prompts ?? []).map((p) => ({
1518
+ prompt: p,
1519
+ response: null
1520
+ }));
1521
+ if (interactions.length > 0) {
1522
+ console.log();
1523
+ console.log(`prompts: ${interactions.length}`);
1524
+ for (let i = 0; i < interactions.length; i++) {
1525
+ const interaction = interactions[i];
1526
+ console.log();
1527
+ console.log(` ${i + 1}. ${truncateLines(interaction.prompt, TRUNCATE_PROMPT)}`);
1528
+ if (interaction.response) {
1529
+ console.log(` \u2192 ${truncateLines(interaction.response, TRUNCATE_RESPONSE_SHOW)}`);
1530
+ }
1531
+ if (interaction.files_touched && interaction.files_touched.length > 0) {
1532
+ for (const file of interaction.files_touched) {
1533
+ console.log(` \u{1F4C4} ${file}`);
1534
+ }
1535
+ }
1536
+ }
1537
+ }
1538
+ } else {
1539
+ console.log("entry: no agentnote note found for this commit");
1540
+ }
1541
+ const adapter = claudeCode;
1542
+ const transcriptPath = adapter.findTranscript(sessionId);
1543
+ if (transcriptPath) {
1544
+ console.log();
1545
+ const stats = await stat(transcriptPath);
1546
+ const sizeKb = (stats.size / 1024).toFixed(1);
1547
+ console.log(`transcript: ${transcriptPath} (${sizeKb} KB)`);
1548
+ }
1549
+ }
1550
+ function renderRatioBar(ratio) {
1551
+ const width = BAR_WIDTH_FULL;
1552
+ const filled = Math.round(ratio / 100 * width);
1553
+ const empty = width - filled;
1554
+ return `[${"\u2588".repeat(filled)}${"\u2591".repeat(empty)}]`;
1555
+ }
1556
+ function truncateLines(text, maxLen) {
1557
+ const firstLine = text.split("\n")[0];
1558
+ if (firstLine.length <= maxLen) return firstLine;
1559
+ return `${firstLine.slice(0, maxLen)}\u2026`;
1560
+ }
1561
+
1562
+ // src/commands/status.ts
1563
+ import { existsSync as existsSync8 } from "node:fs";
1564
+ import { readFile as readFile6 } from "node:fs/promises";
1565
+ var VERSION = "0.1.7";
1566
+ async function status() {
1567
+ console.log(`agentnote v${VERSION}`);
1568
+ console.log();
1569
+ const repoRoot2 = await root();
1570
+ const adapter = claudeCode;
1571
+ const hooksActive = await adapter.isEnabled(repoRoot2);
1572
+ if (hooksActive) {
1573
+ console.log("hooks: active");
1574
+ } else {
1575
+ console.log("hooks: not configured (run 'agentnote init')");
1576
+ }
1577
+ const sessionPath = await sessionFile();
1578
+ if (existsSync8(sessionPath)) {
1579
+ const sid = (await readFile6(sessionPath, "utf-8")).trim();
1580
+ console.log(`session: ${sid.slice(0, 8)}\u2026`);
1581
+ } else {
1582
+ console.log("session: none");
1583
+ }
1584
+ const { stdout } = await gitSafe([
1585
+ "log",
1586
+ "-20",
1587
+ `--format=%(trailers:key=${TRAILER_KEY},valueonly)`
1588
+ ]);
1589
+ const linked = stdout.split("\n").filter((line) => line.trim().length > 0).length;
1590
+ console.log(`linked: ${linked}/20 recent commits`);
1591
+ }
1592
+
1092
1593
  // src/cli.ts
1093
- var VERSION2 = "0.1.0";
1594
+ var VERSION2 = "0.1.7";
1094
1595
  var HELP = `
1095
- agentnote \u2014 remember why your code changed
1596
+ agentnote v${VERSION2} \u2014 remember why your code changed
1096
1597
 
1097
1598
  usage:
1098
1599
  agentnote init set up hooks, workflow, and notes auto-fetch
@@ -1123,6 +1624,9 @@ switch (command) {
1123
1624
  case "status":
1124
1625
  await status();
1125
1626
  break;
1627
+ case "session":
1628
+ await session(args[0]);
1629
+ break;
1126
1630
  case "hook":
1127
1631
  await hook();
1128
1632
  break;