Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ Available on the [VS Code Marketplace](https://marketplace.visualstudio.com/item

### 1. Install the VS Code Extension

This is the entry point to Tracybot. The extension can open AI Blame, automatically initializes Tracybot in the current repository, and automatically installs plugins for any supported agent it detects on your machine.
This is the entry point to Tracybot. The extension can open AI Blame, offers to initialize Tracybot in the repositories you open, and automatically installs plugins for any supported agent it detects on your machine.

You can install the extension directly within VS Code:
1. Open VS Code and go to the **Extensions** view (`Ctrl+Shift+X` or `Cmd+Shift+X`).
Expand All @@ -53,7 +53,7 @@ Alternatively, visit the [Tracybot VS Code Marketplace page](https://marketplace

When the extension activates, it adds an `AI Blame` status bar item on the right side of VS Code.

If Tracybot has not been initialized in the open repository yet, the extension initializes it automatically.
If Tracybot has not been initialized in the open repository yet, the extension asks whether to initialize it: **Initialize**, **Not now** (asked again next time), or **Never for this project**. You can initialize later with the **Tracybot: Initialize in This Repository** command. Once a repository is initialized, the extension asks whether to enable [Research Mode](./docs/research-mode.md) for it.

If you prefer to initialize from the terminal instead, run:

Expand Down
2 changes: 1 addition & 1 deletion docs/research-mode.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ Research Mode is an opt-in feature that lets a Tracybot user share their Tasklet

## Consent

Consent is **per repository**, not machine-wide — agreeing to share one project's Tasklet history doesn't enroll every other repo opened afterward. The first time a repo is opened (and once per repo thereafter, until a decision is made), the VS Code extension shows a prompt:
Consent is **per repository**, not machine-wide — agreeing to share one project's Tasklet history doesn't enroll every other repo opened afterward. Once Tracybot is initialized in a repo (the extension asks before initializing), and each time that repo is opened until a decision is made, the VS Code extension shows a prompt:

> Help improve Tracybot: share this repository's Tasklet history for a study on AI-assisted coding behavior?

Expand Down
2 changes: 1 addition & 1 deletion vscode-extension/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ The extension queries the Git repository to reconstruct the history of AI intera
2. Extracting metadata from commit objects
3. Building a timeline that maps code changes to AI interactions

It also handles setup automatically: initializing Tracybot in a newly opened repository, and installing the plugin for whichever supported agent (OpenCode, Claude Code, Codex) is detected on your machine. No setup steps are required beyond installing the extension itself.
It also handles setup: it asks before initializing Tracybot in a newly opened repository (or run **Tracybot: Initialize in This Repository** from the Command Palette), and installs the plugin for whichever supported agent (OpenCode, Claude Code, Codex) is detected on your machine.

## Installation

Expand Down
4 changes: 4 additions & 0 deletions vscode-extension/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,10 @@
"command": "tracybot-extension.invalidateCache",
"title": "Tracybot: Invalidate History Cache"
},
{
"command": "tracybot-extension.initRepo",
"title": "Tracybot: Initialize in This Repository"
},
{
"command": "tracybot-extension.reviewResearchDigest",
"title": "Tracybot: Manage Research Mode"
Expand Down
42 changes: 38 additions & 4 deletions vscode-extension/src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@ import { getBlameViewHtml } from './blameView';
import { getRepoPath, mergeRemoteNotes } from './utils';
import { checkOpencode } from './pluginCheck';
import { checkHookBasedAgents } from './hookAgentPluginCheck';
import { checkTracyInit } from './tracyInitCheck';
import { checkTracyInit, initializeRepo } from './tracyInitCheck';
import { isTracyInitialized } from './initState';
import { checkResearchModeConsent, enableResearchModeForRepo, pickResearchModeTier } from './research/researchModeCheck';
import { getConsentTier, getOrCreateParticipantId, getParticipantContext, isResearchModeEnabled } from './research/consent';
import { readRepoConsent, writeRepoConsent } from './research/repoConsent';
Expand Down Expand Up @@ -209,11 +210,14 @@ export async function activate(context: vscode.ExtensionContext) {
return undefined;
}

const runInitialChecks = () => {
checkTracyInit(context);
// Research Mode is only asked about once the repo is initialized, since
// there is no Tasklet history to share otherwise.
const runInitialChecks = async () => {
checkOpencode(context);
checkHookBasedAgents(context);
checkResearchModeConsent(context);
if (await checkTracyInit(context)) {
await checkResearchModeConsent(context);
}
};

runInitialChecks();
Expand Down Expand Up @@ -253,6 +257,13 @@ export async function activate(context: vscode.ExtensionContext) {
const consent = readRepoConsent(repoPath);

if (!consent) {
if (!isTracyInitialized(repoPath)) {
const action = await vscode.window.showInformationMessage(
'Research Mode needs Tracybot to be initialized in this repository first.',
'Initialize'
);
if (action !== 'Initialize' || !(await initializeRepo(context))) { return; }
}
await checkResearchModeConsent(context);
await updateResearchStatusBar(context);
return;
Expand Down Expand Up @@ -369,6 +380,17 @@ export async function activate(context: vscode.ExtensionContext) {
const fileTasklets = relativePath ? (fileTaskletsMap.get(relativePath) ?? []) : [];

if (!fileMap || fileMap.size === 0) {
const repoPath = await getRepoPath();
if (repoPath && !isTracyInitialized(repoPath)) {
const action = await vscode.window.showInformationMessage(
'AI Blame: Tracybot isn\'t initialized in this repository, so no AI changes are being recorded.',
'Initialize'
);
if (action === 'Initialize') {
await vscode.commands.executeCommand('tracybot-extension.initRepo');
}
return;
}
vscode.window.showInformationMessage(
`AI Blame: No AI-generated lines found in "${fileName}".`
);
Expand Down Expand Up @@ -415,6 +437,18 @@ export async function activate(context: vscode.ExtensionContext) {
refreshHistory();
};

// Initialize — the way back for a repo that was declined (or dismissed)
// at the prompt checkTracyInit shows on open.
context.subscriptions.push(
vscode.commands.registerCommand('tracybot-extension.initRepo', async () => {
if (await initializeRepo(context)) {
await checkResearchModeConsent(context);
await updateResearchStatusBar(context);
refreshHistory();
}
})
);

// clearCache — clears the history cache
context.subscriptions.push(
vscode.commands.registerCommand('tracybot-extension.invalidateCache', async () => {
Expand Down
137 changes: 137 additions & 0 deletions vscode-extension/src/initState.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
import { test, describe, afterEach } from "node:test";
import assert from "node:assert/strict";
import * as fs from "fs";
import * as os from "os";
import * as path from "path";
import { execSync } from "child_process";
import { clearInitDeclined, getInitState, isTracyInitialized, recordInitDeclined } from "./initState";

const tmpDirs: string[] = [];

afterEach(() => {
for (const dir of tmpDirs.splice(0)) {
fs.rmSync(dir, { recursive: true, force: true });
}
});

function makeRepo(): string {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "tracybot-init-state-"));
tmpDirs.push(dir);
fs.mkdirSync(path.join(dir, ".git"));
return dir;
}

function writeConfig(repo: string, scriptPath: string): void {
fs.mkdirSync(path.join(repo, ".git", "tracybot"), { recursive: true });
fs.writeFileSync(path.join(repo, ".git", "tracybot", "config"), `TRACY_SNAPSHOT_SCRIPT=${scriptPath}\n`);
}

describe("initState", () => {
test("a repo with no Tracybot setup and no decision is undecided", () => {
assert.equal(getInitState(makeRepo()), "undecided");
});

test("a repo whose config points at an existing script is initialized", () => {
const repo = makeRepo();
const script = path.join(repo, "tracy.py");
fs.writeFileSync(script, "");
writeConfig(repo, script);

assert.equal(getInitState(repo), "initialized");
assert.equal(isTracyInitialized(repo), true);
});

test("a repo whose config points at a script that no longer exists needs repair, and still counts as initialized", () => {
const repo = makeRepo();
writeConfig(repo, path.join(repo, "old-extension-version", "tracy.py"));

assert.equal(getInitState(repo), "needs-repair");
assert.equal(isTracyInitialized(repo), true);
});

test("declining is remembered until cleared", () => {
const repo = makeRepo();
recordInitDeclined(repo);
assert.equal(getInitState(repo), "declined");
assert.equal(isTracyInitialized(repo), false);

clearInitDeclined(repo);
assert.equal(getInitState(repo), "undecided");
});

test("an existing setup wins over a leftover decline", () => {
const repo = makeRepo();
recordInitDeclined(repo);
const script = path.join(repo, "tracy.py");
fs.writeFileSync(script, "");
writeConfig(repo, script);

assert.equal(getInitState(repo), "initialized");
});

describe("when .git is a file (linked worktree, submodule)", () => {
function sh(cmd: string, cwd: string): void {
execSync(cmd, { cwd, stdio: "pipe" });
}

function makeRealRepo(): string {
const dir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "tracybot-init-state-real-")));
tmpDirs.push(dir);
sh("git init -q -b main && git config user.email t@e.com && git config user.name T", dir);
fs.writeFileSync(path.join(dir, "a.txt"), "x\n");
sh("git add -A && git commit -q -m init", dir);
return dir;
}

function addLinkedWorktree(main: string): string {
const linked = path.join(path.dirname(main), `linked-${path.basename(main)}`);
tmpDirs.push(linked);
sh(`git worktree add -q ${linked} -b feat`, main);
assert.ok(fs.statSync(path.join(linked, ".git")).isFile(), "test setup: .git should be a file here");
return linked;
}

// init.py writes the config into the common git dir; ask git for it
// rather than assuming ".git", the same way init.py does.
function writeConfigLikeInitPy(repo: string, scriptPath: string): void {
const common = path.resolve(repo, execSync("git rev-parse --git-common-dir", { cwd: repo, encoding: "utf8" }).trim());
fs.mkdirSync(path.join(common, "tracybot"), { recursive: true });
fs.writeFileSync(path.join(common, "tracybot", "config"), `TRACY_SNAPSHOT_SCRIPT=${scriptPath}\n`);
}

test("a linked worktree of an initialized repo counts as initialized", () => {
const main = makeRealRepo();
const script = path.join(main, "tracy.py");
fs.writeFileSync(script, "");
writeConfigLikeInitPy(main, script);
const linked = addLinkedWorktree(main);

assert.equal(getInitState(linked), "initialized");
assert.equal(isTracyInitialized(linked), true);
});

test("declining from a linked worktree is remembered instead of throwing ENOTDIR", () => {
const main = makeRealRepo();
const linked = addLinkedWorktree(main);

recordInitDeclined(linked);
assert.equal(getInitState(linked), "declined");
// One answer per repository, shared with the main checkout.
assert.equal(getInitState(main), "declined");

clearInitDeclined(linked);
assert.equal(getInitState(linked), "undecided");
});

test("declining inside a submodule is remembered instead of throwing ENOTDIR", () => {
const lib = makeRealRepo();
const main = makeRealRepo();
sh(`git -c protocol.file.allow=always submodule add -q ${lib} sub`, main);
const sub = path.join(main, "sub");
assert.ok(fs.statSync(path.join(sub, ".git")).isFile(), "test setup: .git should be a file here");

recordInitDeclined(sub);
assert.equal(getInitState(sub), "declined");
});
});
});
56 changes: 56 additions & 0 deletions vscode-extension/src/initState.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import * as fs from 'fs';
import * as path from 'path';
import { resolveGitDir } from './gitDir';

// Kept free of the 'vscode' module so it can be unit-tested directly.
export type InitState = 'initialized' | 'needs-repair' | 'declined' | 'undecided';

function configPath(repoPath: string): string {
return path.join(resolveGitDir(repoPath), 'tracybot', 'config');
}

// Stored inside .git like research-consent.json, so the choice stays scoped
// to this one repo and is never committed or pushed.
function declinedMarkerPath(repoPath: string): string {
return path.join(resolveGitDir(repoPath), 'tracybot', 'init-declined');
}

// init.py writes TRACY_SNAPSHOT_SCRIPT as an absolute path into *this*
// extension version's own directory (assets/tracking/tracy.py). VS Code
// deletes the previous version's directory on every auto-update, so a repo
// initialized under an older version silently ends up pointing at a script
// that no longer exists. A missing config file's mere *existence* isn't
// enough to call a repo "initialized" — the target it points to has to
// still be there, or every future commit hook run fails to find it.
function isTracySnapshotScriptValid(tracyConfigPath: string): boolean {
try {
const content = fs.readFileSync(tracyConfigPath, 'utf8');
const match = content.match(/^TRACY_SNAPSHOT_SCRIPT=(.+)$/m);
return !!match && fs.existsSync(match[1].trim());
} catch {
return false;
}
}

export function getInitState(repoPath: string): InitState {
const config = configPath(repoPath);
if (fs.existsSync(config)) {
return isTracySnapshotScriptValid(config) ? 'initialized' : 'needs-repair';
}
return fs.existsSync(declinedMarkerPath(repoPath)) ? 'declined' : 'undecided';
}

export function isTracyInitialized(repoPath: string): boolean {
const state = getInitState(repoPath);
return state === 'initialized' || state === 'needs-repair';
}

export function recordInitDeclined(repoPath: string): void {
const marker = declinedMarkerPath(repoPath);
fs.mkdirSync(path.dirname(marker), { recursive: true });
fs.writeFileSync(marker, '');
}

export function clearInitDeclined(repoPath: string): void {
fs.rmSync(declinedMarkerPath(repoPath), { force: true });
}
Loading
Loading