ai-gains 1.4.0 → 1.5.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ai-gains",
3
- "version": "1.4.0",
3
+ "version": "1.5.0",
4
4
  "description": "Interactive browser dashboard for AI development session tracking",
5
5
  "main": "src/server.js",
6
6
  "bin": {
package/src/init.js CHANGED
@@ -5,10 +5,25 @@ const path = require('path');
5
5
 
6
6
  const TEMPLATE_DIR = path.join(__dirname, '..', 'template');
7
7
 
8
+ const STALE_HOOK_TYPES = ['SessionStart', 'Stop'];
9
+ const AI_GAINS_LABEL = 'ai-gains';
10
+
8
11
  function mergeSettings(existing, template) {
9
12
  const result = JSON.parse(JSON.stringify(existing));
10
13
  if (!result.hooks) result.hooks = {};
11
14
 
15
+ // Remove stale ai-gains hooks that no longer exist in the template
16
+ for (const hookType of STALE_HOOK_TYPES) {
17
+ if (!result.hooks[hookType]) continue;
18
+ result.hooks[hookType] = result.hooks[hookType]
19
+ .map(entry => ({
20
+ ...entry,
21
+ hooks: (entry.hooks || []).filter(h => h.label !== AI_GAINS_LABEL)
22
+ }))
23
+ .filter(entry => (entry.hooks || []).length > 0);
24
+ if (result.hooks[hookType].length === 0) delete result.hooks[hookType];
25
+ }
26
+
12
27
  for (const [hookType, templateEntries] of Object.entries(template.hooks || {})) {
13
28
  if (!result.hooks[hookType]) {
14
29
  result.hooks[hookType] = templateEntries;
@@ -89,6 +104,16 @@ function initProject(targetDir) {
89
104
  fs.copyFileSync(skillSrc, skillDst);
90
105
  console.log(` ${skillExisted ? 'updated' : 'created'} .claude/skills/ai-gains/SKILL.md`);
91
106
 
107
+ // Remove stale hook scripts that no longer exist in the template
108
+ const staleScripts = ['session-start.cjs', 'stop.cjs'];
109
+ for (const name of staleScripts) {
110
+ const stalePath = path.join(scriptsDst, name);
111
+ if (fs.existsSync(stalePath)) {
112
+ fs.rmSync(stalePath);
113
+ console.log(` removed .claude/scripts/ai-gains/${name}`);
114
+ }
115
+ }
116
+
92
117
  // Copy hook scripts (safe to overwrite)
93
118
  const scriptsExisted = fs.existsSync(scriptsDst);
94
119
  copyDir(scriptsSrc, scriptsDst);
@@ -0,0 +1,81 @@
1
+ 'use strict';
2
+ const fs = require('fs');
3
+
4
+ const transcriptPath = process.argv[2];
5
+ if (!transcriptPath) {
6
+ console.error('Usage: get-session-times.cjs <transcript_path>');
7
+ process.exit(1);
8
+ }
9
+
10
+ const CHUNK_SIZE = 4096;
11
+ const fd = fs.openSync(transcriptPath, 'r');
12
+ const { size } = fs.fstatSync(fd);
13
+
14
+ // Scan from the start for the first entry with a timestamp
15
+ function findFirstTimestamp() {
16
+ const rl = require('readline').createInterface({
17
+ input: fs.createReadStream(transcriptPath),
18
+ crlfDelay: Infinity
19
+ });
20
+ return new Promise(resolve => {
21
+ rl.on('line', line => {
22
+ if (!line.trim()) return;
23
+ try {
24
+ const entry = JSON.parse(line);
25
+ if (entry.timestamp) {
26
+ rl.close();
27
+ resolve(entry.timestamp);
28
+ }
29
+ } catch {}
30
+ });
31
+ rl.on('close', () => resolve(null));
32
+ });
33
+ }
34
+
35
+ // Scan from the end for the last entry with a timestamp
36
+ function findLastTimestamp() {
37
+ let offset = size;
38
+ let remainder = '';
39
+ let lastTimestamp = null;
40
+
41
+ while (offset > 0) {
42
+ const readSize = Math.min(CHUNK_SIZE, offset);
43
+ offset -= readSize;
44
+ const buf = Buffer.alloc(readSize);
45
+ fs.readSync(fd, buf, 0, readSize, offset);
46
+ const chunk = buf.toString('utf8') + remainder;
47
+ const lines = chunk.split('\n');
48
+ remainder = lines.shift(); // incomplete line at chunk boundary
49
+
50
+ for (let i = lines.length - 1; i >= 0; i--) {
51
+ const line = lines[i].trim();
52
+ if (!line) continue;
53
+ try {
54
+ const entry = JSON.parse(line);
55
+ if (entry.timestamp) {
56
+ lastTimestamp = entry.timestamp;
57
+ break;
58
+ }
59
+ } catch {}
60
+ }
61
+ if (lastTimestamp) break;
62
+ }
63
+
64
+ fs.closeSync(fd);
65
+ return lastTimestamp;
66
+ }
67
+
68
+ (async () => {
69
+ const [start_time, end_time] = await Promise.all([
70
+ findFirstTimestamp(),
71
+ Promise.resolve(findLastTimestamp())
72
+ ]);
73
+
74
+ if (!start_time || !end_time) {
75
+ console.error('Could not extract timestamps from transcript');
76
+ process.exit(1);
77
+ }
78
+
79
+ const duration_minutes = Math.round((new Date(end_time) - new Date(start_time)) / 60000);
80
+ console.log(JSON.stringify({ start_time, end_time, duration_minutes }));
81
+ })();
@@ -4,8 +4,8 @@ let raw = '';
4
4
  process.stdin.setEncoding('utf8');
5
5
  process.stdin.on('data', chunk => { raw += chunk; });
6
6
  process.stdin.on('end', () => {
7
- const { session_id } = JSON.parse(raw);
7
+ const { session_id, transcript_path } = JSON.parse(raw);
8
8
  process.stdout.write(
9
- `Session ID: ${session_id}. IMPORTANT: In your final response, remind the user to update the ai-gains session log (the /ai-gains skill).`
9
+ `Session ID: ${session_id}. Transcript: ${transcript_path}. IMPORTANT: In your final response, remind the user to update the ai-gains session log (the /ai-gains skill).`
10
10
  );
11
11
  });
@@ -1,16 +1,5 @@
1
1
  {
2
2
  "hooks": {
3
- "SessionStart": [
4
- {
5
- "hooks": [
6
- {
7
- "type": "command",
8
- "label": "ai-gains",
9
- "command": "node .claude/scripts/ai-gains/session-start.cjs"
10
- }
11
- ]
12
- }
13
- ],
14
3
  "UserPromptSubmit": [
15
4
  {
16
5
  "hooks": [
@@ -21,17 +10,6 @@
21
10
  }
22
11
  ]
23
12
  }
24
- ],
25
- "Stop": [
26
- {
27
- "hooks": [
28
- {
29
- "type": "command",
30
- "label": "ai-gains",
31
- "command": "node .claude/scripts/ai-gains/stop.cjs"
32
- }
33
- ]
34
- }
35
13
  ]
36
14
  }
37
15
  }
@@ -7,17 +7,11 @@ description: Manages the AI gains session log. Session initialization and user p
7
7
 
8
8
  ## Context
9
9
 
10
- Session tracking is managed automatically via hooks configured in `.claude/settings.json`:
10
+ Session tracking is managed automatically via a hook configured in `.claude/settings.json`:
11
11
 
12
- - **SessionStart hook**: Reads Claude Code's `session_id` from stdin and creates `.ai-gains/<start_time>_<session_id>.json` with `start_time` and `author`. The timestamp uses `-` instead of `:` for cross-platform filename compatibility (e.g. `2026-03-02T09-00-00Z_<session_id>.json`).
13
- - **UserPromptSubmit hook**: Reads the `session_id` from stdin and echoes it into context with a lightweight reminder for Claude to prompt the user to update the log at the end of each response. The skill itself is not loaded automatically — only when the user invokes `/ai-gains`.
14
- - **Stop hook**: Reads the `session_id` from stdin, locates the matching `.ai-gains/*_<session_id>.json` file, and updates `end_time` after every Claude response.
12
+ - **UserPromptSubmit hook**: Reads `session_id` and `transcript_path` from stdin and echoes both into context, along with a lightweight reminder for Claude to prompt the user to update the log at the end of each response. The skill itself is not loaded automatically — only when the user invokes `/ai-gains`.
15
13
 
16
- `duration_minutes` is the wall-clock time from `start_time` to `end_time`. This intentionally includes human review time, approval of actions, reading diffs, etc. — giving a true picture of total time spent with AI vs. without.
17
-
18
- Files are named `<start_time>_<session_id>.json` so they sort chronologically and concurrent sessions never conflict.
19
-
20
- Claude should note the Session ID echoed by each UserPromptSubmit hook and use it to locate the session file.
14
+ `duration_minutes` is derived from the first and last timestamped entries in the session transcript. This reflects actual conversation activity from first message to last response, and intentionally includes human review time, approval of actions, reading diffs, etc. — giving a true picture of total time spent with AI vs. without.
21
15
 
22
16
  ## Proactive Log Reminders
23
17
 
@@ -31,19 +25,24 @@ Claude should use judgment about what counts as "meaningful" -- a single quick a
31
25
 
32
26
  When the user invokes `/ai-gains` or confirms they want to update the log:
33
27
 
34
- 1. Get the current UTC time:
28
+ 1. Get `session_id` and `transcript_path` from the context echoed by the UserPromptSubmit hook.
29
+
30
+ 2. Run `get-session-times.cjs` with the transcript path to get accurate start/end times and duration:
35
31
  ```bash
36
- node -e "console.log(new Date().toISOString().replace(/\.\d{3}Z$/, 'Z'))"
32
+ node .claude/scripts/ai-gains/get-session-times.cjs <transcript_path>
37
33
  ```
34
+ This outputs `{ start_time, end_time, duration_minutes }` derived from the first and last timestamped entries in the transcript.
38
35
 
39
- 2. Use the Session ID echoed by the UserPromptSubmit hook (present in context) to locate the session file:
36
+ 3. Get the author from git config:
37
+ ```bash
38
+ git config user.email
39
+ ```
40
+
41
+ 4. Check if a session file already exists for this session:
40
42
  ```
41
43
  .ai-gains/*_<session_id>.json
42
44
  ```
43
-
44
- 3. Read the session file to get `start_time` and `end_time`.
45
-
46
- 4. Calculate `duration_minutes` as the difference between `end_time` and `start_time` in minutes (wall-clock time). This includes all time spent in the session — AI working, human reviewing, approving actions, reading output, etc.
45
+ If it exists, read the existing `achievements` array to use as a starting point for merging.
47
46
 
48
47
  5. Reflect on all work done this session: research done, features built, bugs fixed, problems solved, code reviewed, debugging done, documentation updated, etc.
49
48
 
@@ -60,14 +59,16 @@ When the user invokes `/ai-gains` or confirms they want to update the log:
60
59
  - `ui-ux` — designing or improving user interfaces and user experiences
61
60
  - `other` — anything that doesn't fit the above
62
61
 
63
- 8. Write the updated JSON back to `.ai-gains/<start_utc_timestamp>_<uuid>.json`, preserving existing fields. The JSON structure should look like this:
62
+ 8. Merge new achievements with any existing ones from step 4. If a prior achievement is superseded or refined by new work in the same area, update it in place rather than duplicating it.
63
+
64
+ 9. Write the session file to `.ai-gains/<start_time_with_colons_replaced>_<session_id>.json`. The filename uses `-` instead of `:` in the timestamp for cross-platform compatibility. The JSON structure should look like this:
64
65
 
65
66
  ```json
66
67
  {
67
- "uuid": "<session-uuid>",
68
+ "session_id": "<session-id>",
68
69
  "start_time": "<ISO start time>",
70
+ "end_time": "<ISO end time>",
69
71
  "author": "<git user email>",
70
- "end_time": "<ISO current time>",
71
72
  "duration_minutes": "<number>",
72
73
  "achievements": [
73
74
  {
@@ -80,4 +81,4 @@ When the user invokes `/ai-gains` or confirms they want to update the log:
80
81
  }
81
82
  ```
82
83
 
83
- 8. Confirm to the user that the log has been updated and summarize the key achievements.
84
+ 10. Confirm to the user that the log has been updated and summarize the key achievements.
@@ -1,24 +0,0 @@
1
- 'use strict';
2
- const fs = require('fs');
3
- const { execSync } = require('child_process');
4
-
5
- let raw = '';
6
- process.stdin.setEncoding('utf8');
7
- process.stdin.on('data', chunk => { raw += chunk; });
8
- process.stdin.on('end', () => {
9
- const { session_id } = JSON.parse(raw);
10
- const start_time = new Date().toISOString().replace(/\.\d{3}Z$/, 'Z');
11
-
12
- let author = 'unknown';
13
- try {
14
- author = execSync('git config user.email', { stdio: ['pipe', 'pipe', 'pipe'] })
15
- .toString().trim();
16
- } catch {}
17
-
18
- const safeTs = start_time.replace(/:/g, '-');
19
- fs.mkdirSync('.ai-gains', { recursive: true });
20
- fs.writeFileSync(
21
- `.ai-gains/${safeTs}_${session_id}.json`,
22
- JSON.stringify({ session_id, start_time, author }, null, 2) + '\n'
23
- );
24
- });
@@ -1,16 +0,0 @@
1
- 'use strict';
2
- const fs = require('fs');
3
-
4
- let raw = '';
5
- process.stdin.setEncoding('utf8');
6
- process.stdin.on('data', chunk => { raw += chunk; });
7
- process.stdin.on('end', () => {
8
- const { session_id } = JSON.parse(raw);
9
- const match = fs.readdirSync('.ai-gains').find(f => f.endsWith(`_${session_id}.json`));
10
- if (!match) return;
11
- const file = `.ai-gains/${match}`;
12
-
13
- const session = JSON.parse(fs.readFileSync(file, 'utf8'));
14
- session.end_time = new Date().toISOString().replace(/\.\d{3}Z$/, 'Z');
15
- fs.writeFileSync(file, JSON.stringify(session, null, 2) + '\n');
16
- });