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
12 changes: 6 additions & 6 deletions docs/research-mode.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,24 +8,25 @@ Consent is **per repository**, not machine-wide — agreeing to share one projec

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

Agreeing shows a Tier picker (Tier 1, the most conservative, is the default if dismissed) and generates a random, per-machine `participant_id` that is never derived from git identity (`user.name`/`user.email`) — the participant identity is shared across repos even though the enable/tier decision isn't. The decision (`enabled` + tier, or `declined`) is stored in that repo's `.git/tracybot/research-consent.json` — inside `.git` so it's never itself tracked or pushed by that repo's own history — and can be changed anytime from the Research Mode status bar item ("Disable for This Repo").
Agreeing shows a Tier picker (Tier 1, the most conservative option, is the default if dismissed) and generates a random, per-machine `participant_id` that is never derived from git identity (`user.name`/`user.email`) — the participant identity is shared across repos even though the enable/tier decision isn't. The decision (`enabled` + tier, or `declined`) is stored in that repo's `.git/tracybot/research-consent.json` — inside `.git` so it's never itself tracked or pushed by that repo's own history — and can be changed anytime from the Research Mode status bar item ("Disable for This Repo").

### Consent tiers

Tiers are additive — each includes everything in the tier below it.

| Tier | Adds |
|---|---|
| **1** (default) | Model, timestamps, file extensions touched, line-change counts, ownership-flip/BLEU stats. No prompt text, no code. |
| **2** | Plan/build prompt and response text (fenced code blocks in responses are redacted before leaving the machine). |
| **3** | The diff hunks touched by each Tasklet (`added_lines`/`removed_lines`), plus the BLEU-significance result per hunk. Never a full file or repo snapshot. |
| **1** (default) | Model, timestamps, file extensions touched, line-change counts, ownership-flip/BLEU stats, the taskletIds of prior Tasklets that previously owned any line this one currently owns (`history_tasklet_ids`), plus plan/build prompt and response text (fenced code blocks in responses are redacted before leaving the machine). |
| **2** | The diff hunks touched by each Tasklet (`added_lines`/`removed_lines`), plus the BLEU-significance result per hunk. Never a full file or repo snapshot. |

The developer's own (non-AI) code is never collected, regardless of tier.

## What gets collected

One payload per Tasklet, built by `vscode-extension/src/research/buildResearchPayloads.ts` from `buildHistory()`'s output. See `vscode-extension/src/research/types.ts` for the exact field list per tier.

`repo_url` is read live from the repo's `origin` remote (`vscode-extension/src/research/gitRemote.ts`) at submission time — it isn't something the participant sets, and isn't stored in the consent file. There's no separate consent gate for it: for an open-source repo the URL doesn't tell us anything not already public, and for a private one the URL alone doesn't grant access either way.

## Architecture

```
Expand Down Expand Up @@ -62,8 +63,7 @@ Stored per-repo in `.git/tracybot/research-consent.json` (see `vscode-extension/
| Field | Purpose |
|---|---|
| `decision` | `"enabled"` or `"declined"` — absent entirely means undecided (prompt shows next time the repo is opened) |
| `consentTier` | `1`, `2`, or `3` — see tiers above; only present when `decision` is `"enabled"` |
| `repoUrl` | Optional, not currently set through any UI — lets researchers later check if a repo is open source, if manually added to the file |
| `consentTier` | `1` or `2` — see tiers above; only present when `decision` is `"enabled"` |

## Local development

Expand Down
20 changes: 16 additions & 4 deletions research-collector-worker/src/schema.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,13 @@ function makeTier1Payload(overrides: Record<string, unknown> = {}) {
ownership_flip: false,
bleu_score: null,
review_latency_sec: null,
history_tasklet_ids: [],
consent_level: 1,
plan_prompts: ["Plan this"],
plan_responses: ["Here's the plan"],
build_prompt: "Build it",
build_response: "Done",
questions_answers: [],
...overrides,
};
}
Expand All @@ -37,10 +43,16 @@ describe("submitRequestSchema", () => {
assert.equal(result.success, false);
});

test("rejects a Tier 2 payload missing the Tier 2-only fields", () => {
const result = submitRequestSchema.safeParse({
payloads: [makeTier1Payload({ consent_level: 2 })],
});
test("rejects a Tier 1 payload missing the Tier 1-only fields", () => {
const {
plan_prompts,
plan_responses,
build_prompt,
build_response,
questions_answers,
...baseOnly
} = makeTier1Payload();
const result = submitRequestSchema.safeParse({ payloads: [baseOnly] });
assert.equal(result.success, false);
});

Expand Down
15 changes: 7 additions & 8 deletions research-collector-worker/src/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,19 +27,19 @@ const basePayload = z.object({
ownership_flip: z.boolean(),
bleu_score: z.number().nullable(),
review_latency_sec: z.number().nullable(),
});

const tier1Payload = basePayload.extend({ consent_level: z.literal(1) });
history_tasklet_ids: z.array(z.string()),
});

const tier2Fields = {
const tier1Fields = {
plan_prompts: z.array(z.string()),
plan_responses: z.array(z.string()),
build_prompt: z.string(),
build_response: z.string(),
questions_answers: z.array(z.object({ question: z.string(), answer: z.array(z.string()) })),
};

const tier2Payload = basePayload.extend({ consent_level: z.literal(2), ...tier2Fields });
const tier1Payload = basePayload.extend({ consent_level: z.literal(1), ...tier1Fields });

const diffHunk = z.object({
file: z.string(),
Expand All @@ -51,17 +51,16 @@ const diffHunk = z.object({
removed_lines: z.array(z.string()),
});

const tier3Payload = basePayload.extend({
consent_level: z.literal(3),
...tier2Fields,
const tier2Payload = basePayload.extend({
consent_level: z.literal(2),
...tier1Fields,
diff_hunks: z.array(diffHunk),
hunk_significance: z.array(z.boolean()),
});

export const taskletResearchPayloadSchema = z.discriminatedUnion("consent_level", [
tier1Payload,
tier2Payload,
tier3Payload,
]);

export type TaskletResearchPayload = z.infer<typeof taskletResearchPayloadSchema>;
Expand Down
20 changes: 14 additions & 6 deletions vscode-extension/src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { checkOpencode } from './pluginCheck';
import { checkHookBasedAgents } from './hookAgentPluginCheck';
import { checkTracyInit } from './tracyInitCheck';
import { checkResearchModeConsent, enableResearchModeForRepo, pickResearchModeTier } from './research/researchModeCheck';
import { getConsentTier, getParticipantContext, isResearchModeEnabled } from './research/consent';
import { getConsentTier, getOrCreateParticipantId, getParticipantContext, isResearchModeEnabled } from './research/consent';
import { readRepoConsent, writeRepoConsent } from './research/repoConsent';
import { clearPendingPayloads, getPendingPayloads, getTodaysSentCount, queueTaskletForSubmission } from './research/queue';
import { buildTaskletResearchPayloads } from './research/buildResearchPayloads';
Expand Down Expand Up @@ -142,7 +142,7 @@ async function updateResearchStatusBar(ctx: vscode.ExtensionContext): Promise<vo
async function processNewTaskletsForResearch(ctx: vscode.ExtensionContext, h: History, repoPath: string): Promise<void> {
if (!isResearchModeEnabled(repoPath)) { return; }

const participant = getParticipantContext(ctx, repoPath);
const participant = await getParticipantContext(ctx, repoPath);
const payloads = buildTaskletResearchPayloads(h, getConsentTier(repoPath), participant);

for (const payload of payloads) {
Expand Down Expand Up @@ -277,17 +277,25 @@ export async function activate(context: vscode.ExtensionContext) {
const action = await vscode.window.showInformationMessage(
`Tracybot Research Mode: sent ${count} tasklet${count === 1 ? '' : 's'} today. ` +
`${pending.length} total pending.`,
'View Pending Data',
'View Pending',
'Copy ID',
'Change Tier',
'Disable for This Repo'
'Disable'
);

if (action === 'View Pending Data') {
if (action === 'View Pending') {
const doc = await vscode.workspace.openTextDocument({
content: JSON.stringify(pending, null, 2),
language: 'json',
});
await vscode.window.showTextDocument(doc);
} else if (action === 'Copy ID') {
// Only reachable from this already-opted-in menu — nothing shows this
// to a participant who hasn't enabled Research Mode. Useful for e.g. a
// classroom study where students self-report this id through a
// separate, out-of-band roster rather than the tool ever identifying them.
await vscode.env.clipboard.writeText(getOrCreateParticipantId(context));
vscode.window.showInformationMessage('Participant ID copied to clipboard.');
} else if (action === 'Change Tier') {
const tier = await pickResearchModeTier();
// Dismissed: leave the existing tier untouched — this is a change,
Expand All @@ -296,7 +304,7 @@ export async function activate(context: vscode.ExtensionContext) {
writeRepoConsent(repoPath, { ...consent, consentTier: tier });
vscode.window.showInformationMessage(`Research Mode tier changed to Tier ${tier} for this repository.`);
}
} else if (action === 'Disable for This Repo') {
} else if (action === 'Disable') {
writeRepoConsent(repoPath, { decision: 'declined' });
vscode.window.showInformationMessage('Tracybot Research Mode disabled for this repository.');
await updateResearchStatusBar(context);
Expand Down
101 changes: 86 additions & 15 deletions vscode-extension/src/research/buildResearchPayloads.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { test, describe } from "node:test";
import assert from "node:assert/strict";
import { History, TaskletMessage } from "../history/types";
import { buildTaskletResearchPayloads } from "./buildResearchPayloads";
import { ParticipantContext, Tier2Payload, Tier3Payload } from "./types";
import { ParticipantContext, Tier1Payload, Tier2Payload } from "./types";

const PARTICIPANT: ParticipantContext = {
participantId: "p_test",
Expand Down Expand Up @@ -65,9 +65,9 @@ describe("buildTaskletResearchPayloads", () => {
assert.equal(p.files_touched_count, 1);
assert.equal(p.lines_changed_total, 2);
assert.equal(p.ownership_flip, false);
assert.deepEqual(p.history_tasklet_ids, []);

// Tier 1 must not leak Tier 2/3 fields
assert.equal("plan_prompts" in p, false);
// Tier 1 must not leak Tier 2-only fields
assert.equal("diff_hunks" in p, false);
});

Expand Down Expand Up @@ -251,7 +251,7 @@ describe("buildTaskletResearchPayloads", () => {
});
});

describe("Tier 2 fields", () => {
describe("Tier 1 fields", () => {
test("redacts fenced code blocks in plan/build responses", () => {
const messages: TaskletMessage[] = [
{ stage: "plan", type: "prompt", model: "anthropic/claude-sonnet-4-6", message: "Plan this" },
Expand All @@ -266,7 +266,7 @@ describe("buildTaskletResearchPayloads", () => {
},
];
const history = makeHistory([{ path: "src/app.ts", tasklets: [makeTasklet({ messages })] }]);
const payloads = buildTaskletResearchPayloads(history, 2, PARTICIPANT, SUBMITTED_AT) as Tier2Payload[];
const payloads = buildTaskletResearchPayloads(history, 1, PARTICIPANT, SUBMITTED_AT) as Tier1Payload[];

assert.equal(payloads[0].plan_responses[0], "Here's the plan.\n\n[code omitted]\n\nDone.");
assert.equal(payloads[0].build_response, "Done.\n\n[code omitted]");
Expand All @@ -286,7 +286,7 @@ describe("buildTaskletResearchPayloads", () => {
},
];
const history = makeHistory([{ path: "src/app.ts", tasklets: [makeTasklet({ messages })] }]);
const payloads = buildTaskletResearchPayloads(history, 2, PARTICIPANT, SUBMITTED_AT) as Tier2Payload[];
const payloads = buildTaskletResearchPayloads(history, 1, PARTICIPANT, SUBMITTED_AT) as Tier1Payload[];

assert.ok(!payloads[0].build_response.includes("esme"), "username should not leak");
assert.ok(!payloads[0].build_response.includes("secret-client"), "local project name should not leak");
Expand All @@ -308,7 +308,7 @@ describe("buildTaskletResearchPayloads", () => {
},
];
const history = makeHistory([{ path: "src/app.ts", tasklets: [makeTasklet({ messages })] }]);
const payloads = buildTaskletResearchPayloads(history, 2, PARTICIPANT, SUBMITTED_AT) as Tier2Payload[];
const payloads = buildTaskletResearchPayloads(history, 1, PARTICIPANT, SUBMITTED_AT) as Tier1Payload[];

assert.equal(
payloads[0].build_response,
Expand All @@ -335,18 +335,18 @@ describe("buildTaskletResearchPayloads", () => {
],
},
]);
const payloads = buildTaskletResearchPayloads(history, 2, PARTICIPANT, SUBMITTED_AT) as Tier2Payload[];
const payloads = buildTaskletResearchPayloads(history, 1, PARTICIPANT, SUBMITTED_AT) as Tier1Payload[];
assert.deepEqual(payloads[0].questions_answers, [{ question: "Which log level?", answer: ["DEBUG"] }]);
});

test("Tier 2 payload does not include Tier 3 diff_hunks", () => {
test("Tier 1 payload does not include Tier 2 diff_hunks", () => {
const history = makeHistory([{ path: "src/app.ts", tasklets: [makeTasklet()] }]);
const payloads = buildTaskletResearchPayloads(history, 2, PARTICIPANT, SUBMITTED_AT);
const payloads = buildTaskletResearchPayloads(history, 1, PARTICIPANT, SUBMITTED_AT);
assert.equal("diff_hunks" in payloads[0], false);
});
});

describe("Tier 3 fields", () => {
describe("Tier 2 fields", () => {
test("flattens diff_hunks across files with correct file tagging and field mapping", () => {
const history = makeHistory([
{
Expand Down Expand Up @@ -378,7 +378,7 @@ describe("buildTaskletResearchPayloads", () => {
],
},
]);
const payloads = buildTaskletResearchPayloads(history, 3, PARTICIPANT, SUBMITTED_AT) as Tier3Payload[];
const payloads = buildTaskletResearchPayloads(history, 2, PARTICIPANT, SUBMITTED_AT) as Tier2Payload[];
const p = payloads[0];

assert.equal(p.diff_hunks.length, 2);
Expand Down Expand Up @@ -414,20 +414,91 @@ describe("buildTaskletResearchPayloads", () => {
],
},
]);
const payloads = buildTaskletResearchPayloads(history, 3, PARTICIPANT, SUBMITTED_AT) as Tier3Payload[];
const payloads = buildTaskletResearchPayloads(history, 2, PARTICIPANT, SUBMITTED_AT) as Tier2Payload[];
assert.deepEqual(payloads[0].diff_hunks[0].added_lines, []);
assert.deepEqual(payloads[0].diff_hunks[0].removed_lines, []);
assert.equal(payloads[0].hunk_significance[0], false);
});

test("Tier 3 still includes Tier 2 fields", () => {
test("Tier 2 still includes Tier 1 fields", () => {
const history = makeHistory([{ path: "src/app.ts", tasklets: [makeTasklet()] }]);
const payloads = buildTaskletResearchPayloads(history, 3, PARTICIPANT, SUBMITTED_AT) as Tier3Payload[];
const payloads = buildTaskletResearchPayloads(history, 2, PARTICIPANT, SUBMITTED_AT) as Tier2Payload[];
assert.ok(Array.isArray(payloads[0].plan_prompts));
assert.ok(Array.isArray(payloads[0].questions_answers));
});
});

describe("history_tasklet_ids", () => {
test("lists the Tasklet that previously owned a now-taken-over line", () => {
const history = makeHistory([
{
path: "src/app.ts",
tasklets: [
// Originally wrote lines 1-3; line 2 was later taken over by tasklet-b.
makeTasklet({ taskletId: "tasklet-a", sessionId: "session-a", lines: [1, 3], ghostLines: [2] }),
// Now owns line 2.
makeTasklet({ taskletId: "tasklet-b", sessionId: "session-b", lines: [2], ghostLines: [] }),
],
},
]);
const payloads = buildTaskletResearchPayloads(history, 1, PARTICIPANT, SUBMITTED_AT);

const a = payloads.find(p => p.tasklet_id === "tasklet-a")!;
const b = payloads.find(p => p.tasklet_id === "tasklet-b")!;
// a's currently-live lines (1, 3) were never written by anyone else first.
assert.deepEqual(a.history_tasklet_ids, []);
// b's currently-live line (2) was previously written by a.
assert.deepEqual(b.history_tasklet_ids, ["tasklet-a"]);
});

test("dedupes repeated predecessors across multiple lines", () => {
const history = makeHistory([
{
path: "src/app.ts",
tasklets: [
makeTasklet({ taskletId: "tasklet-a", sessionId: "session-a", lines: [], ghostLines: [1, 2] }),
makeTasklet({ taskletId: "tasklet-b", sessionId: "session-b", lines: [1, 2], ghostLines: [] }),
],
},
]);
const payloads = buildTaskletResearchPayloads(history, 1, PARTICIPANT, SUBMITTED_AT);

const b = payloads.find(p => p.tasklet_id === "tasklet-b")!;
assert.deepEqual(b.history_tasklet_ids, ["tasklet-a"]);
});

test("aggregates history across every file the Tasklet touched", () => {
const history = makeHistory([
{
path: "src/app.ts",
tasklets: [
makeTasklet({ taskletId: "tasklet-a", sessionId: "session-a", lines: [], ghostLines: [1] }),
makeTasklet({ taskletId: "tasklet-c", sessionId: "session-c", lines: [1], ghostLines: [] }),
],
},
{
path: "src/other.ts",
tasklets: [
makeTasklet({ taskletId: "tasklet-b", sessionId: "session-b", lines: [], ghostLines: [5] }),
{ ...makeTasklet({ taskletId: "tasklet-c", sessionId: "session-c", lines: [5], ghostLines: [] }), id: "snapshot-hash-2" },
],
},
]);
const payloads = buildTaskletResearchPayloads(history, 1, PARTICIPANT, SUBMITTED_AT);

const c = payloads.find(p => p.tasklet_id === "tasklet-c")!;
assert.deepEqual([...c.history_tasklet_ids].sort(), ["tasklet-a", "tasklet-b"]);
});

test("does not include a Tasklet's own id even if it still ghost-owns a line elsewhere", () => {
const history = makeHistory([
{ path: "src/app.ts", tasklets: [makeTasklet({ lines: [1], ghostLines: [2] })] },
]);
const payloads = buildTaskletResearchPayloads(history, 1, PARTICIPANT, SUBMITTED_AT);
assert.deepEqual(payloads[0].history_tasklet_ids, []);
});
});

test("model_provider/model_id split on the build-stage message's model field", () => {
const messages: TaskletMessage[] = [
{ stage: "build", type: "prompt", model: "openai/gpt-5", message: "Build it" },
Expand Down
Loading
Loading