@rachel_rotenberg/ai-contribution-tracker 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/index.ts +295 -0
  2. package/package.json +32 -0
package/index.ts ADDED
@@ -0,0 +1,295 @@
1
+ /**
2
+ * AI Contribution Tracker — OpenCode Plugin (v3)
3
+ *
4
+ * KEY DESIGN: gitDir is resolved ONLY from file paths in tool.execute.after,
5
+ * never from cwd. This ensures we write to the repo the agent is actually
6
+ * working in, even when OpenCode opens a parent directory with multiple repos.
7
+ */
8
+ import type { Plugin } from "@opencode-ai/plugin" with { "resolution-mode": "import" };
9
+ // Workaround: @opencode-ai/plugin references HeadersInit which is missing in Node types
10
+ declare global { type HeadersInit = unknown; }
11
+ import * as fs from "fs";
12
+ import * as path from "path";
13
+ import { execSync } from "child_process";
14
+ import * as os from "os";
15
+
16
+ interface TokenTotals { inputTokens: number; outputTokens: number; cachedTokens: number; reasoningTokens: number; }
17
+ interface TrackerState {
18
+ promptCount: number; subagentCount: number; mainAgentTypes: string[]; subagentTypes: string[];
19
+ activeSubagents: number; models: string[]; subagentModels: string[]; sessionId: string | null;
20
+ stateCreatedAt: string; lastUpdated: string; tokensByModel: Record<string, TokenTotals>;
21
+ }
22
+ interface SessionState {
23
+ gitDir: string | null;
24
+ isSubagent: boolean;
25
+ agentName: string;
26
+ pendingPrompts: number; // counted before gitDir is known
27
+ pendingModels: string[]; // collected before gitDir is known
28
+ lastTokens: Map<string, TokenTotals>;
29
+ }
30
+
31
+ // ─── Git / State helpers ────────────────────────────────────
32
+ function findGitDir(cwd: string): string | null {
33
+ const dotGit = path.join(cwd, ".git");
34
+ if (fs.existsSync(dotGit)) {
35
+ if (fs.statSync(dotGit).isDirectory()) return dotGit;
36
+ const m = fs.readFileSync(dotGit, "utf8").trim().match(/^gitdir:\s*(.+)$/);
37
+ if (m) { const r = path.resolve(cwd, m[1]); if (fs.existsSync(r)) return r; }
38
+ }
39
+ try {
40
+ const r = path.resolve(cwd, execSync("git rev-parse --git-dir", { cwd, encoding: "utf8", stdio: ["pipe","pipe","pipe"] }).trim());
41
+ if (fs.existsSync(r)) return r;
42
+ } catch { /* never crash OpenCode */ }
43
+ return null;
44
+ }
45
+ function getStatePath(g: string) { return path.join(g, "ai-tracker-state.json"); }
46
+ function getFlagPath(g: string) { return path.join(g, "AI_IMPACT_PENDING"); }
47
+ function loadState(g: string): TrackerState {
48
+ const p = getStatePath(g);
49
+ if (fs.existsSync(p)) { try {
50
+ const s = JSON.parse(fs.readFileSync(p, "utf8")) as TrackerState;
51
+ s.mainAgentTypes ??= []; s.subagentTypes ??= []; s.models ??= []; s.subagentModels ??= []; s.tokensByModel ??= {};
52
+ if (typeof s.subagentCount !== "number") s.subagentCount = 0;
53
+ if (typeof s.activeSubagents !== "number") s.activeSubagents = 0;
54
+ if (typeof s.stateCreatedAt !== "string") s.stateCreatedAt = s.lastUpdated || new Date().toISOString();
55
+ return s;
56
+ } catch { /* never crash OpenCode */ } }
57
+ return { promptCount: 0, subagentCount: 0, mainAgentTypes: [], subagentTypes: [], activeSubagents: 0,
58
+ models: [], subagentModels: [], sessionId: null, stateCreatedAt: new Date().toISOString(),
59
+ lastUpdated: new Date().toISOString(), tokensByModel: {} };
60
+ }
61
+ function saveState(g: string, s: TrackerState) { s.lastUpdated = new Date().toISOString(); fs.writeFileSync(getStatePath(g), JSON.stringify(s, null, 2)); }
62
+ function formatK(n: number) { return n >= 1000 ? `${Math.round(n / 1000)}k` : String(n); }
63
+ function formatMarker(s: TrackerState): string {
64
+ const p: string[] = [];
65
+ const ma = [...new Set(s.mainAgentTypes)]; if (ma.length) p.push(`Agent mode: ${ma.join(", ")}`);
66
+ if (s.models.length) p.push(`Model: ${s.models.join(", ")}`);
67
+ if (s.promptCount > 0) p.push(`Prompts: ${s.promptCount}`);
68
+ const sa = [...new Set(s.subagentTypes)]; if (sa.length) p.push(`Sub-agents mode: ${sa.join(", ")}`);
69
+ if (s.subagentCount > 0) p.push(`sub-Agent prompts: ${s.subagentCount}`);
70
+ const te = Object.entries(s.tokensByModel);
71
+ if (te.length) { p.push(`Tokens: ${te.map(([m, t]) => {
72
+ let r = `${m}: ${formatK(t.inputTokens)} in/${formatK(t.outputTokens)} out`;
73
+ if (t.cachedTokens > 0) r += ` (${formatK(t.cachedTokens)} cached)`;
74
+ if (t.reasoningTokens > 0) r += ` +${formatK(t.reasoningTokens)} reasoning`;
75
+ return r;
76
+ }).join(" | ")}`); }
77
+ return p.length ? `Impacted by AI (${p.join(" | ")})` : "Impacted by AI";
78
+ }
79
+ function writeFlag(g: string, s: TrackerState) {
80
+ if (s.promptCount === 0 && s.mainAgentTypes.length === 0 && s.subagentTypes.length === 0 && s.subagentCount === 0 && Object.keys(s.tokensByModel).length === 0) return;
81
+ const fp = getFlagPath(g), marker = formatMarker(s);
82
+ if (fs.existsSync(fp)) {
83
+ const ex = fs.readFileSync(fp, "utf8").trim();
84
+ if (ex.includes("Inline")) {
85
+ // Merge: preserve Inline marker, add agent data
86
+ const inner = marker.match(/\((.+)\)$/)?.[1];
87
+ fs.writeFileSync(fp, inner ? `Impacted by AI (Inline + ${inner})` : "Impacted by AI (Inline)");
88
+ return;
89
+ }
90
+ // Always overwrite with latest state — state only grows, never shrinks
91
+ }
92
+ fs.writeFileSync(fp, marker);
93
+ }
94
+
95
+ // ─── Plugin ─────────────────────────────────────────────────
96
+
97
+ // ─── Auto Git Hook Installation ─────────────────────────────
98
+ function appendOrCreateHook(hooksDir: string) {
99
+ const hookPath = path.join(hooksDir, "commit-msg");
100
+ const hookBody = [
101
+ "",
102
+ "# AI Contribution Tracker — reads AI_IMPACT_PENDING flag",
103
+ 'IMPACT_FLAG=$(git rev-parse --git-path AI_IMPACT_PENDING)',
104
+ 'STATE_FILE=$(git rev-parse --git-path ai-tracker-state.json)',
105
+ 'if [ -f "$IMPACT_FLAG" ]; then',
106
+ ' MARKER=$(cat "$IMPACT_FLAG")',
107
+ ' if [ -z "$MARKER" ]; then MARKER="Impacted by AI"; fi',
108
+ ' if ! grep -qF "$MARKER" "$1"; then',
109
+ ' echo "" >> "$1"',
110
+ ' echo "$MARKER" >> "$1"',
111
+ ' fi',
112
+ ' rm "$IMPACT_FLAG"',
113
+ 'fi',
114
+ 'if [ -f "$STATE_FILE" ]; then rm "$STATE_FILE"; fi',
115
+ ].join("\n");
116
+ if (fs.existsSync(hookPath)) {
117
+ const existing = fs.readFileSync(hookPath, "utf8");
118
+ if (existing.includes("AI_IMPACT_PENDING")) return;
119
+ fs.appendFileSync(hookPath, "\n" + hookBody + "\n");
120
+ } else {
121
+ fs.writeFileSync(hookPath, "#!/bin/sh\n" + hookBody + "\n");
122
+ }
123
+ try { fs.chmodSync(hookPath, "755"); } catch { /* Windows */ }
124
+ }
125
+
126
+ function ensureGitHook() {
127
+ try {
128
+ const existingPath = execSync("git config --global core.hooksPath", {
129
+ encoding: "utf8", stdio: ["pipe", "pipe", "pipe"],
130
+ }).trim();
131
+ // Ensure the configured hooks dir exists (it may have been deleted)
132
+ fs.mkdirSync(existingPath, { recursive: true });
133
+ appendOrCreateHook(existingPath);
134
+ } catch {
135
+ const hooksDir = path.join(os.homedir(), ".config", "ai-contribution-tracker", "git-hooks");
136
+ fs.mkdirSync(hooksDir, { recursive: true });
137
+ appendOrCreateHook(hooksDir);
138
+ try {
139
+ execSync(`git config --global core.hooksPath "${hooksDir}"`, {
140
+ encoding: "utf8", stdio: ["pipe", "pipe", "pipe"],
141
+ });
142
+ } catch { /* git not available */ }
143
+ }
144
+ }
145
+ const sessions = new Map<string, SessionState>();
146
+
147
+ function extractSessionId(event: any): string | null {
148
+ const p = event?.properties; if (!p) return null;
149
+ if (typeof p.sessionID === "string" && p.sessionID) return p.sessionID;
150
+ const i = p.info; if (!i) return null;
151
+ return i.sessionID || i.session_id || i.id || null;
152
+ }
153
+
154
+ function getOrCreateSession(sid: string, agent?: string): SessionState {
155
+ let sess = sessions.get(sid);
156
+ if (sess) return sess;
157
+ // gitDir starts null — resolved ONLY from file paths in tool.execute.after
158
+ sess = { gitDir: null, isSubagent: false, agentName: agent ?? "session", pendingPrompts: 0, pendingModels: [], lastTokens: new Map() };
159
+ sessions.set(sid, sess);
160
+ return sess;
161
+ }
162
+
163
+ /** Called when gitDir is first resolved — flushes pending prompts/models to disk */
164
+ function flushPending(sess: SessionState, sid: string) {
165
+ if (!sess.gitDir) return;
166
+ const state = loadState(sess.gitDir);
167
+ state.sessionId = sid;
168
+ const src = `opencode/${sess.agentName}`;
169
+ if (!state.mainAgentTypes.includes(src)) state.mainAgentTypes.push(src);
170
+ state.promptCount += sess.pendingPrompts;
171
+ for (const m of sess.pendingModels) { if (!state.models.includes(m)) state.models.push(m); }
172
+ sess.pendingPrompts = 0;
173
+ sess.pendingModels = [];
174
+ saveState(sess.gitDir, state);
175
+ // Only write flag if there is meaningful activity to report
176
+ if (state.promptCount > 0 || state.subagentCount > 0 || Object.keys(state.tokensByModel).length > 0) {
177
+ writeFlag(sess.gitDir, state);
178
+ }
179
+ }
180
+
181
+ const AIContributionTracker: Plugin = async ({ directory, worktree }) => {
182
+ const cwd = worktree || directory;
183
+ try { ensureGitHook(); } catch { /* never block plugin init */ }
184
+ return {
185
+ event: async ({ event }) => {
186
+ try {
187
+ if (event.type === "session.created") {
188
+ const sid = extractSessionId(event);
189
+ if (!sid) return;
190
+ const info = (event as any).properties?.info;
191
+ sessions.set(sid, {
192
+ gitDir: null, // NEVER resolve from cwd — wait for file paths
193
+ isSubagent: Boolean(info?.parentID),
194
+ agentName: info?.agent ?? "session",
195
+ pendingPrompts: 0, pendingModels: [],
196
+ lastTokens: new Map(),
197
+ });
198
+ return;
199
+ }
200
+ if (event.type === "session.idle" || (event.type === "session.status" && (event as any).properties?.status?.type === "idle")) {
201
+ const sid = extractSessionId(event);
202
+ if (!sid) return;
203
+ const sess = sessions.get(sid);
204
+ if (!sess || sess.isSubagent || !sess.gitDir) return;
205
+ writeFlag(sess.gitDir, loadState(sess.gitDir));
206
+ return;
207
+ }
208
+ if (event.type === "session.deleted") { const sid = extractSessionId(event); if (sid) sessions.delete(sid); return; }
209
+ if (event.type === "message.updated") {
210
+ const info = (event as any).properties?.info;
211
+ if (!info) return;
212
+ const sid = info.sessionID || info.session_id;
213
+ if (!sid) return;
214
+ const sess = getOrCreateSession(sid);
215
+ if (sess.isSubagent || !sess.gitDir) return; // skip if gitDir not yet resolved
216
+ if (info.role !== "assistant" || !info.finish || !info.tokens) return;
217
+ const modelId = info.modelID ?? "unknown";
218
+ const msgId = info.id || info.messageID;
219
+ const cur: TokenTotals = { inputTokens: Number(info.tokens.input ?? 0), outputTokens: Number(info.tokens.output ?? 0),
220
+ cachedTokens: Number(info.tokens.cache?.read ?? 0), reasoningTokens: Number(info.tokens.reasoning ?? 0) };
221
+ const prev = msgId ? sess.lastTokens.get(msgId) : undefined;
222
+ // Clamp deltas to >= 0 (snapshots can decrease; we never subtract)
223
+ const delta: TokenTotals = {
224
+ inputTokens: Math.max(0, cur.inputTokens - (prev?.inputTokens ?? 0)),
225
+ outputTokens: Math.max(0, cur.outputTokens - (prev?.outputTokens ?? 0)),
226
+ cachedTokens: Math.max(0, cur.cachedTokens - (prev?.cachedTokens ?? 0)),
227
+ reasoningTokens: Math.max(0, cur.reasoningTokens - (prev?.reasoningTokens ?? 0)),
228
+ };
229
+ if (msgId) sess.lastTokens.set(msgId, cur);
230
+ // Skip if no meaningful delta across any token type
231
+ if (delta.inputTokens <= 0 && delta.outputTokens <= 0 && delta.cachedTokens <= 0 && delta.reasoningTokens <= 0) return;
232
+ const state = loadState(sess.gitDir);
233
+ const ex = state.tokensByModel[modelId];
234
+ if (ex) { ex.inputTokens += delta.inputTokens; ex.outputTokens += delta.outputTokens; ex.cachedTokens += delta.cachedTokens; ex.reasoningTokens += delta.reasoningTokens; }
235
+ else { state.tokensByModel[modelId] = { ...delta }; }
236
+ saveState(sess.gitDir, state);
237
+ writeFlag(sess.gitDir, state);
238
+ }
239
+ } catch { /* never crash OpenCode */ }
240
+ },
241
+ "chat.message": async (input, _output) => {
242
+ try {
243
+ const sess = getOrCreateSession(input.sessionID, input.agent);
244
+ if (sess.isSubagent) return;
245
+ if (sess.gitDir) {
246
+ // gitDir known — write directly
247
+ const state = loadState(sess.gitDir);
248
+ const src = `opencode/${input.agent ?? "session"}`;
249
+ if (!state.mainAgentTypes.includes(src)) state.mainAgentTypes.push(src);
250
+ state.promptCount += 1;
251
+ if (input.model?.modelID && !state.models.includes(input.model.modelID)) state.models.push(input.model.modelID);
252
+ saveState(sess.gitDir, state);
253
+ writeFlag(sess.gitDir, state);
254
+ } else {
255
+ // gitDir NOT known yet — buffer in memory
256
+ sess.pendingPrompts += 1;
257
+ if (input.agent) sess.agentName = input.agent;
258
+ if (input.model?.modelID && !sess.pendingModels.includes(input.model.modelID)) sess.pendingModels.push(input.model.modelID);
259
+ }
260
+ } catch { /* never crash OpenCode */ }
261
+ },
262
+ "tool.execute.after": async (input, _output) => {
263
+ try {
264
+ const sess = getOrCreateSession(input.sessionID);
265
+ if (sess.isSubagent) return;
266
+
267
+ // Resolve gitDir from file path — this is the ONLY place we resolve
268
+ if (!sess.gitDir) {
269
+ const args = input.args as Record<string, unknown> ?? {};
270
+ const fp = typeof args.filePath === "string" ? args.filePath : typeof args.path === "string" ? args.path : undefined;
271
+ if (fp) {
272
+ const abs = path.isAbsolute(fp) ? fp : path.resolve(cwd, fp);
273
+ sess.gitDir = findGitDir(path.dirname(abs));
274
+ if (sess.gitDir) flushPending(sess, input.sessionID);
275
+ }
276
+ }
277
+ if (!sess.gitDir) return;
278
+
279
+ if (input.tool === "task") {
280
+ const args = input.args as Record<string, unknown> ?? {};
281
+ // Validate agentType is a string to avoid [object Object] in markers
282
+ const rawType = args.subagent_type || args.category;
283
+ const agentType = typeof rawType === "string" ? rawType : "task";
284
+ const state = loadState(sess.gitDir);
285
+ state.subagentCount += 1;
286
+ if (!state.subagentTypes.includes(agentType)) state.subagentTypes.push(agentType);
287
+ saveState(sess.gitDir, state);
288
+ writeFlag(sess.gitDir, state); // keep flag current for commit-before-idle
289
+ }
290
+ } catch { /* never crash OpenCode */ }
291
+ },
292
+ };
293
+ };
294
+ export default AIContributionTracker;
295
+ // Named exports omitted — OpenCode calls all exported functions as plugins
package/package.json ADDED
@@ -0,0 +1,32 @@
1
+ {
2
+ "name": "@rachel_rotenberg/ai-contribution-tracker",
3
+ "version": "1.0.0",
4
+ "description": "OpenCode plugin \u2014 tracks AI coding sessions and tags git commits with Impacted by AI markers",
5
+ "main": "index.ts",
6
+ "types": "index.ts",
7
+ "files": [
8
+ "index.ts"
9
+ ],
10
+ "exports": {
11
+ ".": {
12
+ "import": "./index.ts",
13
+ "default": "./index.ts"
14
+ }
15
+ },
16
+ "keywords": [
17
+ "opencode",
18
+ "opencode-plugin",
19
+ "ai",
20
+ "tracking",
21
+ "git",
22
+ "commits"
23
+ ],
24
+ "license": "MIT",
25
+ "repository": {
26
+ "type": "git",
27
+ "url": "https://github.com/Varonis-Systems/AI-Contribution-Tracker"
28
+ },
29
+ "peerDependencies": {
30
+ "@opencode-ai/plugin": ">=1.0.0"
31
+ }
32
+ }