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 +1 -1
- package/src/init.js +25 -0
- package/template/.claude/scripts/ai-gains/get-session-times.cjs +81 -0
- package/template/.claude/scripts/ai-gains/user-prompt-submit.cjs +2 -2
- package/template/.claude/settings.json +0 -22
- package/template/.claude/skills/ai-gains/SKILL.md +21 -20
- package/template/.claude/scripts/ai-gains/session-start.cjs +0 -24
- package/template/.claude/scripts/ai-gains/stop.cjs +0 -16
package/package.json
CHANGED
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
|
|
10
|
+
Session tracking is managed automatically via a hook configured in `.claude/settings.json`:
|
|
11
11
|
|
|
12
|
-
- **
|
|
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
|
|
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
|
|
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 -
|
|
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
|
-
|
|
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.
|
|
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
|
-
"
|
|
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
|
-
|
|
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
|
-
});
|