@giwonn/claude-daily-review 0.3.13 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -13,7 +13,7 @@
13
13
  "source": {
14
14
  "source": "url",
15
15
  "url": "https://github.com/giwonn/claude-daily-review.git",
16
- "ref": "v0.3.13"
16
+ "ref": "v0.4.0"
17
17
  },
18
18
  "description": "Auto-capture conversations for daily review and career documentation",
19
19
  "author": {
@@ -65,9 +65,24 @@ This outputs JSON with:
65
65
  - `needs.quarterly`: quarters that need quarterly summary
66
66
  - `needs.yearly`: years that need yearly summary
67
67
  - `logs`: raw conversation logs keyed by date
68
+ - `gitActivity`: git commit entries keyed by date (each with `action`, `hash`, `branch`, `message`, `remote`, `ghAccount`, `cwd`)
68
69
 
69
70
  If `needs` is all empty, tell the user "해당 기간에 생성할 회고가 없습니다." and stop.
70
71
 
72
+ ## Step 1.5: Git Account Access Check
73
+
74
+ If `gitActivity` has entries, verify account access before generating reviews:
75
+
76
+ 1. Collect unique `ghAccount` values from all git entries
77
+ 2. Run `gh auth status` to get the list of currently authenticated accounts
78
+ 3. For each `ghAccount` NOT in the authenticated list, ask the user via AskUserQuestion:
79
+ > "다음 GitHub 계정의 커밋 이력이 있지만, 현재 gh에 로그인되어 있지 않습니다: `{account}`"
80
+ - option 1: label: "로그인하기", description: "gh auth login으로 인증합니다"
81
+ - option 2: label: "해당 커밋 무시", description: "이 계정의 커밋 정보 없이 진행합니다"
82
+ - If "로그인하기": Tell the user to run `! gh auth login` in their terminal, then verify with `gh auth status`
83
+ - If "해당 커밋 무시": Remove that account's entries from `gitActivity`
84
+ 4. Remember the original active account (`gh auth status --active`) so you can restore it later
85
+
71
86
  ## Step 2: Apply Filters
72
87
 
73
88
  - **Project filter**: If the user requested a specific project, filter `logs[date]` entries to only include those where the last path segment of `cwd` matches the project name.
@@ -84,8 +99,27 @@ For each date in `needs.daily`:
84
99
  - **배운 것**: New things learned
85
100
  - **고민한 포인트**: Decisions and reasoning
86
101
  - **질문과 답변**: Key Q&A (summarized)
102
+ - **커밋 내역**: If `gitActivity[date]` has entries for this project's cwd (see below)
87
103
  4. General questions go under "미분류"
88
104
 
105
+ ### Using Git Activity in Daily Reviews
106
+
107
+ If `gitActivity[date]` has entries matching a project (by `cwd`):
108
+
109
+ 1. Switch gh account if needed: `gh auth switch --user <ghAccount>`
110
+ 2. Parse `remote` to extract `owner/repo`:
111
+ - SSH format `git@github.com:owner/repo.git` → `owner/repo`
112
+ - HTTPS format `https://github.com/owner/repo.git` → `owner/repo`
113
+ 3. Fetch commit details: `gh api repos/{owner}/{repo}/commits/{hash} --jq '.files[].filename'` to see changed files
114
+ 4. Use the conversation context + commit info to describe what was actually implemented
115
+ 5. After all lookups for a given account, switch back to the original account
116
+
117
+ Include in the review:
118
+ ```markdown
119
+ **커밋 내역:**
120
+ - [`{short_hash}`](https://github.com/{owner}/{repo}/commit/{hash}) — {message}
121
+ ```
122
+
89
123
  Write via:
90
124
  ```bash
91
125
  echo '<content>' | CLAUDE_PLUGIN_DATA="${CLAUDE_PLUGIN_DATA}" node "${CLAUDE_PLUGIN_ROOT}/lib/storage-cli.mjs" write "daily/{date}.md"
@@ -193,3 +227,9 @@ Tell the user what was generated:
193
227
  > - 연간 요약: {count}개
194
228
 
195
229
  Only show lines where count > 0.
230
+
231
+ If git activity was included, also report:
232
+ > - 커밋 연동: {count}개 커밋 반영
233
+
234
+ If any git entries were skipped (account not logged in), note:
235
+ > - ⚠ 일부 커밋은 GitHub 계정 미연동으로 제외됨
package/hooks/on-stop.mjs CHANGED
@@ -1,10 +1,12 @@
1
1
  #!/usr/bin/env node
2
2
  // @ts-check
3
3
  import { loadConfig, createStorageAdapter } from '../lib/config.mjs';
4
- import { parseHookInput, appendRawLog } from '../lib/raw-logger.mjs';
4
+ import { parseHookInput, appendRawLog, appendGitLogs } from '../lib/raw-logger.mjs';
5
+ import { parseGitActivity } from '../lib/git-parser.mjs';
5
6
  import { getRawDir } from '../lib/vault.mjs';
6
7
  import { formatDate } from '../lib/periods.mjs';
7
8
  import { readFileSync, writeFileSync, mkdirSync } from 'fs';
9
+ import { execSync } from 'child_process';
8
10
  import { dirname, join } from 'path';
9
11
 
10
12
  /**
@@ -58,6 +60,36 @@ async function main() {
58
60
  const date = formatDate(new Date());
59
61
  await appendRawLog(storage, sessionDir, date, input);
60
62
 
63
+ // Extract and save git activity from transcript
64
+ if (input.transcript_path) {
65
+ const gitEntries = parseGitActivity(input.transcript_path);
66
+ if (gitEntries.length > 0) {
67
+ // Resolve remote URL for each unique cwd
68
+ /** @type {Map<string, string>} */
69
+ const remoteByDir = new Map();
70
+ for (const entry of gitEntries) {
71
+ if (!entry.cwd || remoteByDir.has(entry.cwd)) continue;
72
+ try {
73
+ const remote = execSync('git remote get-url origin', { cwd: entry.cwd, encoding: 'utf-8', timeout: 5000 }).trim();
74
+ remoteByDir.set(entry.cwd, remote);
75
+ } catch { remoteByDir.set(entry.cwd, ''); }
76
+ }
77
+ for (const entry of gitEntries) {
78
+ entry.remote = remoteByDir.get(entry.cwd) || '';
79
+ }
80
+
81
+ // Get current gh account
82
+ let ghAccount = '';
83
+ try {
84
+ const status = execSync('gh auth status 2>&1', { encoding: 'utf-8', timeout: 5000 });
85
+ const match = status.match(/Logged in to github\.com account (\S+)/);
86
+ if (match) ghAccount = match[1];
87
+ } catch { /* gh not available or not logged in */ }
88
+
89
+ await appendGitLogs(storage, sessionDir, date, gitEntries, input.session_id, ghAccount);
90
+ }
91
+ }
92
+
61
93
  // Save transcript path locally for session recovery (not to remote storage)
62
94
  if (input.transcript_path) {
63
95
  const dataDir = process.env.CLAUDE_PLUGIN_DATA;
@@ -23,19 +23,25 @@ function acquireLock() {
23
23
 
24
24
  mkdirSync(dirname(lockPath), { recursive: true });
25
25
 
26
- // Check for stale lock
27
- if (existsSync(lockPath)) {
26
+ const lockData = JSON.stringify({ pid: process.pid, timestamp: new Date().toISOString() });
27
+
28
+ try {
29
+ writeFileSync(lockPath, lockData, { flag: 'wx' });
30
+ return true;
31
+ } catch {
32
+ // File exists — check if stale
28
33
  try {
29
34
  const lock = JSON.parse(readFileSync(lockPath, 'utf-8'));
30
35
  const age = Date.now() - new Date(lock.timestamp).getTime();
31
- if (age < LOCK_STALE_MS) return false; // Another session is recovering
32
- } catch { /* corrupt lock, take over */ }
36
+ if (age < LOCK_STALE_MS) return false;
37
+ unlinkSync(lockPath);
38
+ // Retry once after removing stale lock
39
+ try {
40
+ writeFileSync(lockPath, lockData, { flag: 'wx' });
41
+ return true;
42
+ } catch { return false; }
43
+ } catch { return false; }
33
44
  }
34
-
35
- try {
36
- writeFileSync(lockPath, JSON.stringify({ pid: process.pid, timestamp: new Date().toISOString() }));
37
- return true;
38
- } catch { return false; }
39
45
  }
40
46
 
41
47
  function releaseLock() {
@@ -69,6 +69,8 @@ async function main() {
69
69
  const sessions = await storage.list('.raw');
70
70
  /** @type {Record<string, Array<{type: string, message: string, cwd: string, timestamp: string}>>} */
71
71
  const logsByDate = {};
72
+ /** @type {Record<string, Array<{action: string, hash: string, branch: string, message: string, remote: string, ghAccount: string, cwd: string, timestamp: string}>>} */
73
+ const gitByDate = {};
72
74
  const affectedDates = new Set();
73
75
 
74
76
  for (const sess of sessions) {
@@ -86,6 +88,25 @@ async function main() {
86
88
  for (const line of content.trim().split('\n')) {
87
89
  try {
88
90
  const entry = JSON.parse(line);
91
+
92
+ if (entry.type === 'git') {
93
+ if (!gitByDate[date]) gitByDate[date] = [];
94
+ gitByDate[date].push({
95
+ action: entry.action || '',
96
+ hash: entry.hash || '',
97
+ branch: entry.branch || '',
98
+ message: entry.message || '',
99
+ remote: entry.remote || '',
100
+ ghAccount: entry.ghAccount || '',
101
+ cwd: entry.cwd || '',
102
+ timestamp: entry.timestamp || '',
103
+ });
104
+ if (!lastGenerated || entry.timestamp > lastGenerated) {
105
+ affectedDates.add(date);
106
+ }
107
+ continue;
108
+ }
109
+
89
110
  if (!logsByDate[date]) logsByDate[date] = [];
90
111
  logsByDate[date].push({
91
112
  type: entry.type || 'unknown',
@@ -104,7 +125,7 @@ async function main() {
104
125
 
105
126
  // 2. If no new entries, nothing to do
106
127
  if (affectedDates.size === 0) {
107
- console.log(JSON.stringify({ needs: { daily: [], weekly: [], monthly: [], quarterly: [], yearly: [] }, logs: {} }));
128
+ console.log(JSON.stringify({ needs: { daily: [], weekly: [], monthly: [], quarterly: [], yearly: [] }, logs: {}, gitActivity: {} }));
108
129
  return;
109
130
  }
110
131
 
@@ -126,8 +147,10 @@ async function main() {
126
147
 
127
148
  // 4. Only include logs for affected dates
128
149
  const filteredLogs = {};
150
+ const filteredGit = {};
129
151
  for (const date of affectedDates) {
130
- filteredLogs[date] = logsByDate[date];
152
+ if (logsByDate[date]) filteredLogs[date] = logsByDate[date];
153
+ if (gitByDate[date]) filteredGit[date] = gitByDate[date];
131
154
  }
132
155
 
133
156
  // 5. Output
@@ -145,6 +168,7 @@ async function main() {
145
168
  yearly: [...affectedYears].sort(),
146
169
  },
147
170
  logs: filteredLogs,
171
+ gitActivity: filteredGit,
148
172
  };
149
173
 
150
174
  console.log(JSON.stringify(result));
@@ -0,0 +1,79 @@
1
+ // @ts-check
2
+ /** @typedef {import('./types.d.ts').GitEntry} GitEntry */
3
+
4
+ import { readFileSync } from 'fs';
5
+
6
+ /** @param {string} content @returns {Array<GitEntry>} */
7
+ function extractGitEntries(content) {
8
+ const lines = content.trim().split('\n');
9
+
10
+ /** @type {Map<string, {command: string, cwd: string, timestamp: string}>} */
11
+ const gitToolUses = new Map();
12
+ /** @type {Array<GitEntry>} */
13
+ const entries = [];
14
+
15
+ for (const line of lines) {
16
+ let parsed;
17
+ try { parsed = JSON.parse(line); } catch { continue; }
18
+ if (parsed.type !== 'assistant' || !parsed.message?.content) continue;
19
+
20
+ const contents = Array.isArray(parsed.message.content) ? parsed.message.content : [];
21
+ const timestamp = parsed.timestamp || '';
22
+ const cwd = parsed.cwd || '';
23
+
24
+ for (const block of contents) {
25
+ if (block.type === 'tool_use' && block.name === 'Bash' && block.input?.command) {
26
+ const cmd = block.input.command;
27
+ if (/\bgit\s+commit\b/.test(cmd)) {
28
+ gitToolUses.set(block.id, { command: cmd, cwd, timestamp });
29
+ }
30
+ }
31
+
32
+ if (block.type === 'tool_result' && block.tool_use_id && gitToolUses.has(block.tool_use_id)) {
33
+ if (block.is_error) continue;
34
+ const toolUse = /** @type {{command: string, cwd: string, timestamp: string}} */ (gitToolUses.get(block.tool_use_id));
35
+ const output = typeof block.content === 'string' ? block.content : '';
36
+ if (!output) continue;
37
+
38
+ const commitInfo = parseCommitOutput(output);
39
+ if (commitInfo) {
40
+ entries.push({
41
+ action: 'commit',
42
+ hash: commitInfo.hash,
43
+ branch: commitInfo.branch,
44
+ message: commitInfo.message,
45
+ cwd: cwd || toolUse.cwd,
46
+ timestamp: timestamp || toolUse.timestamp,
47
+ });
48
+ }
49
+ }
50
+ }
51
+ }
52
+
53
+ return entries;
54
+ }
55
+
56
+ /**
57
+ * Parse git commit output: "[branch hash] message"
58
+ * @param {string} output
59
+ * @returns {{hash: string, branch: string, message: string} | null}
60
+ */
61
+ function parseCommitOutput(output) {
62
+ // Matches: [main abc1234] commit message
63
+ // Also: [main (root-commit) abc1234] first commit
64
+ const match = output.match(/\[([^\s\]]+)(?:\s+\([^)]+\))?\s+([a-f0-9]+)\]\s+(.+)/);
65
+ if (!match) return null;
66
+ return { branch: match[1], hash: match[2], message: match[3].trim() };
67
+ }
68
+
69
+ /**
70
+ * Parse transcript file for git commit activity.
71
+ * @param {string} transcriptPath
72
+ * @returns {Array<GitEntry>}
73
+ */
74
+ export function parseGitActivity(transcriptPath) {
75
+ try {
76
+ const content = readFileSync(transcriptPath, 'utf-8');
77
+ return extractGitEntries(content);
78
+ } catch { return []; }
79
+ }
@@ -15,21 +15,35 @@ export class GitHubStorageAdapter {
15
15
  }
16
16
 
17
17
  /** @private @param {string} path @returns {string} */
18
- getUrl(path) { return this.basePath ? `${this.baseUrl}/${this.basePath}/${path}` : `${this.baseUrl}/${path}`; }
18
+ getUrl(path) {
19
+ if (path.split('/').includes('..')) throw new Error('Invalid path: traversal not allowed');
20
+ return this.basePath ? `${this.baseUrl}/${this.basePath}/${path}` : `${this.baseUrl}/${path}`;
21
+ }
22
+
23
+ /**
24
+ * @private
25
+ * @param {string} url
26
+ * @param {RequestInit} [options]
27
+ * @returns {Promise<Record<string, unknown> | null>}
28
+ */
29
+ async fetchOrNull(url, options) {
30
+ const res = await fetch(url, { ...options, headers: this.headers });
31
+ if (res.status === 404) return null;
32
+ if (!res.ok) throw new Error(`GitHub API error: ${res.status}`);
33
+ return /** @type {Record<string, unknown>} */ (await res.json());
34
+ }
19
35
 
20
36
  /** @private @param {string} path @returns {Promise<string | null>} */
21
37
  async getSha(path) {
22
- const res = await fetch(this.getUrl(path), { method: 'GET', headers: this.headers });
23
- if (res.status === 404) return null;
24
- const data = /** @type {Record<string, unknown>} */ (await res.json());
38
+ const data = await this.fetchOrNull(this.getUrl(path), { method: 'GET' });
39
+ if (!data) return null;
25
40
  return /** @type {string | null} */ (data.sha || null);
26
41
  }
27
42
 
28
43
  /** @param {string} path @returns {Promise<string | null>} */
29
44
  async read(path) {
30
- const res = await fetch(this.getUrl(path), { method: 'GET', headers: this.headers });
31
- if (res.status === 404) return null;
32
- const data = /** @type {Record<string, unknown>} */ (await res.json());
45
+ const data = await this.fetchOrNull(this.getUrl(path), { method: 'GET' });
46
+ if (!data) return null;
33
47
  return Buffer.from(/** @type {string} */ (data.content), 'base64').toString('utf-8');
34
48
  }
35
49
 
@@ -40,10 +54,13 @@ export class GitHubStorageAdapter {
40
54
  const body = { message: `update ${path}`, content: Buffer.from(content).toString('base64') };
41
55
  if (sha) body.sha = sha;
42
56
  const res = await fetch(this.getUrl(path), { method: 'PUT', headers: this.headers, body: JSON.stringify(body) });
43
- if (!res.ok && res.status === 409) {
57
+ if (res.status === 409) {
44
58
  const freshSha = await this.getSha(path);
45
59
  if (freshSha) body.sha = freshSha;
46
- await fetch(this.getUrl(path), { method: 'PUT', headers: this.headers, body: JSON.stringify(body) });
60
+ const retry = await fetch(this.getUrl(path), { method: 'PUT', headers: this.headers, body: JSON.stringify(body) });
61
+ if (!retry.ok) throw new Error(`GitHub API error: ${retry.status}`);
62
+ } else if (!res.ok) {
63
+ throw new Error(`GitHub API error: ${res.status}`);
47
64
  }
48
65
  }
49
66
 
@@ -55,16 +72,14 @@ export class GitHubStorageAdapter {
55
72
 
56
73
  /** @param {string} path @returns {Promise<boolean>} */
57
74
  async exists(path) {
58
- const res = await fetch(this.getUrl(path), { method: 'GET', headers: this.headers });
59
- return res.status !== 404;
75
+ const data = await this.fetchOrNull(this.getUrl(path), { method: 'GET' });
76
+ return data !== null;
60
77
  }
61
78
 
62
79
  /** @param {string} dir @returns {Promise<string[]>} */
63
80
  async list(dir) {
64
- const res = await fetch(this.getUrl(dir), { method: 'GET', headers: this.headers });
65
- if (res.status === 404) return [];
66
- const data = await res.json();
67
- if (!Array.isArray(data)) return [];
81
+ const data = await this.fetchOrNull(this.getUrl(dir), { method: 'GET' });
82
+ if (!data || !Array.isArray(data)) return [];
68
83
  return data.map((/** @type {{ name: string }} */ entry) => entry.name);
69
84
  }
70
85
 
@@ -73,9 +88,8 @@ export class GitHubStorageAdapter {
73
88
 
74
89
  /** @param {string} path @returns {Promise<boolean>} */
75
90
  async isDirectory(path) {
76
- const res = await fetch(this.getUrl(path), { method: 'GET', headers: this.headers });
77
- if (res.status === 404) return false;
78
- const data = await res.json();
91
+ const data = await this.fetchOrNull(this.getUrl(path), { method: 'GET' });
92
+ if (!data) return false;
79
93
  return Array.isArray(data);
80
94
  }
81
95
  }
@@ -10,6 +10,8 @@ export function parseHookInput(raw) {
10
10
  return /** @type {HookInput} */ (parsed);
11
11
  }
12
12
 
13
+ /** @typedef {import('./types.d.ts').GitEntry} GitEntry */
14
+
13
15
  /** @param {StorageAdapter} storage @param {string} sessionDir @param {string} date @param {HookInput} entry @returns {Promise<void>} */
14
16
  export async function appendRawLog(storage, sessionDir, date, entry) {
15
17
  await storage.mkdir(sessionDir);
@@ -31,3 +33,23 @@ export async function appendRawLog(storage, sessionDir, date, entry) {
31
33
  await storage.append(logPath, lines);
32
34
  }
33
35
  }
36
+
37
+ /**
38
+ * Append git activity entries to raw log.
39
+ * @param {StorageAdapter} storage
40
+ * @param {string} sessionDir
41
+ * @param {string} date
42
+ * @param {Array<GitEntry>} gitEntries
43
+ * @param {string} sessionId
44
+ * @param {string} ghAccount
45
+ * @returns {Promise<void>}
46
+ */
47
+ export async function appendGitLogs(storage, sessionDir, date, gitEntries, sessionId, ghAccount) {
48
+ if (gitEntries.length === 0) return;
49
+ await storage.mkdir(sessionDir);
50
+ const logPath = `${sessionDir}/${date}.jsonl`;
51
+ const lines = gitEntries.map(e =>
52
+ JSON.stringify({ type: 'git', action: e.action, hash: e.hash, branch: e.branch, message: e.message, remote: e.remote, cwd: e.cwd, ghAccount, session_id: sessionId, timestamp: e.timestamp })
53
+ ).join('\n') + '\n';
54
+ await storage.append(logPath, lines);
55
+ }
package/lib/storage.mjs CHANGED
@@ -2,7 +2,7 @@
2
2
  /** @typedef {import('./types.d.ts').StorageAdapter} StorageAdapter */
3
3
 
4
4
  import { readFileSync, writeFileSync, appendFileSync, existsSync, mkdirSync, readdirSync, statSync } from 'fs';
5
- import { dirname, join } from 'path';
5
+ import { dirname, join, resolve as pathResolve } from 'path';
6
6
 
7
7
  /** @implements {StorageAdapter} */
8
8
  export class LocalStorageAdapter {
@@ -14,7 +14,11 @@ export class LocalStorageAdapter {
14
14
 
15
15
  /** @private @param {string} path @returns {string} */
16
16
  resolve(path) {
17
- return join(this.basePath, path);
17
+ const full = pathResolve(this.basePath, path);
18
+ if (full !== this.basePath && !full.startsWith(this.basePath + '/')) {
19
+ throw new Error('Invalid path: traversal outside base directory');
20
+ }
21
+ return full;
18
22
  }
19
23
 
20
24
  /** @param {string} path @returns {Promise<string | null>} */
package/lib/types.d.ts CHANGED
@@ -55,6 +55,16 @@ export interface HookInput {
55
55
  [key: string]: unknown;
56
56
  }
57
57
 
58
+ export interface GitEntry {
59
+ action: 'commit';
60
+ hash: string;
61
+ branch: string;
62
+ message?: string;
63
+ remote?: string;
64
+ cwd: string;
65
+ timestamp: string;
66
+ }
67
+
58
68
  export interface DeviceCodeResponse {
59
69
  device_code: string;
60
70
  user_code: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@giwonn/claude-daily-review",
3
- "version": "0.3.13",
3
+ "version": "0.4.0",
4
4
  "type": "module",
5
5
  "description": "Claude Code plugin that auto-captures conversations for daily review and career documentation",
6
6
  "repository": {