diff --git a/docs/research-mode.md b/docs/research-mode.md index a2f213f..32f46ca 100644 --- a/docs/research-mode.md +++ b/docs/research-mode.md @@ -8,7 +8,7 @@ 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 @@ -16,9 +16,8 @@ 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. @@ -26,6 +25,8 @@ The developer's own (non-AI) code is never collected, regardless of tier. 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 ``` @@ -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 diff --git a/research-collector-worker/src/schema.test.ts b/research-collector-worker/src/schema.test.ts index 2c3e7c3..dfd97b4 100644 --- a/research-collector-worker/src/schema.test.ts +++ b/research-collector-worker/src/schema.test.ts @@ -21,7 +21,13 @@ function makeTier1Payload(overrides: Record = {}) { 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, }; } @@ -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); }); diff --git a/research-collector-worker/src/schema.ts b/research-collector-worker/src/schema.ts index 3a90c5b..fe7e105 100644 --- a/research-collector-worker/src/schema.ts +++ b/research-collector-worker/src/schema.ts @@ -27,11 +27,11 @@ 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(), @@ -39,7 +39,7 @@ const tier2Fields = { 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(), @@ -51,9 +51,9 @@ 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()), }); @@ -61,7 +61,6 @@ const tier3Payload = basePayload.extend({ export const taskletResearchPayloadSchema = z.discriminatedUnion("consent_level", [ tier1Payload, tier2Payload, - tier3Payload, ]); export type TaskletResearchPayload = z.infer; diff --git a/vscode-extension/src/extension.ts b/vscode-extension/src/extension.ts index 42ab872..2302569 100644 --- a/vscode-extension/src/extension.ts +++ b/vscode-extension/src/extension.ts @@ -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'; @@ -142,7 +142,7 @@ async function updateResearchStatusBar(ctx: vscode.ExtensionContext): Promise { 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) { @@ -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, @@ -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); diff --git a/vscode-extension/src/research/buildResearchPayloads.test.ts b/vscode-extension/src/research/buildResearchPayloads.test.ts index 6185f89..679f9ef 100644 --- a/vscode-extension/src/research/buildResearchPayloads.test.ts +++ b/vscode-extension/src/research/buildResearchPayloads.test.ts @@ -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", @@ -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); }); @@ -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" }, @@ -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]"); @@ -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"); @@ -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, @@ -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([ { @@ -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); @@ -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" }, diff --git a/vscode-extension/src/research/buildResearchPayloads.ts b/vscode-extension/src/research/buildResearchPayloads.ts index 4cb19ab..021c64c 100644 --- a/vscode-extension/src/research/buildResearchPayloads.ts +++ b/vscode-extension/src/research/buildResearchPayloads.ts @@ -70,6 +70,84 @@ function groupByTasklet(history: History): TaskletGroup[] { return Array.from(groups.values()); } +// path -> line -> the taskletId that currently, live-ly owns that line. +// history.files[path].tasklets is chronological (oldest -> newest), so +// whichever entry lists a line in its (live) `lines` last is the current +// owner — ghostLines never contribute here, by definition. +function buildLineOwnership(history: History): Map> { + const ownership = new Map>(); + + for (const file of history.files) { + const lineOwner = new Map(); + for (const tasklet of file.tasklets) { + if (!tasklet.taskletId) { continue; } + for (const line of tasklet.lines) { + lineOwner.set(line, tasklet.taskletId); + } + } + ownership.set(file.path, lineOwner); + } + + return ownership; +} + +// path -> line -> chronological, deduped list of taskletIds that touched +// that line (live or ghost) but are not its current owner — i.e. the +// "previous Tasklets for this line" the AI Blame panel shows, keyed by +// taskletId instead of the per-file snapshot id so it lines up with the +// tasklet_id already in the payload. +function buildLineHistory(history: History, ownership: Map>): Map> { + const history_ = new Map>(); + + for (const file of history.files) { + const lineOwner = ownership.get(file.path)!; + const lineHistory = new Map(); + + for (const tasklet of file.tasklets) { + if (!tasklet.taskletId) { continue; } + + // Unlike the AI Blame panel's dropdown, a Tasklet that's since been + // fully overridden (no live lines left anywhere) still belongs in the + // history — it's exactly the rewrite-chain data this field exists for. + const touched = new Set([...tasklet.lines, ...tasklet.ghostLines]); + for (const line of touched) { + if (lineOwner.get(line) === tasklet.taskletId) { continue; } + + const ids = lineHistory.get(line) ?? []; + if (!ids.includes(tasklet.taskletId)) { ids.push(tasklet.taskletId); } + lineHistory.set(line, ids); + } + } + + history_.set(file.path, lineHistory); + } + + return history_; +} + +// For each line this Tasklet currently (live-ly) owns, who wrote it before — +// deduped across all its files/lines, in roughly chronological order. +function buildHistoryTaskletIds(group: TaskletGroup, lineHistory: Map>): string[] { + const seen = new Set(); + const ids: string[] = []; + + for (const f of group.files) { + const fileHistory = lineHistory.get(f.path); + if (!fileHistory) { continue; } + + for (const line of f.lines) { + for (const id of fileHistory.get(line) ?? []) { + if (!seen.has(id)) { + seen.add(id); + ids.push(id); + } + } + } + } + + return ids; +} + // The build-stage prompt carries the model that actually produced the code; // falls back to any message with a model set (e.g. plan-only Tasklets). function splitModel(messages: TaskletMessage[]): { provider: string; modelId: string } { @@ -152,7 +230,12 @@ function messagesByStage(messages: TaskletMessage[], stage: "plan" | "build", ty return messages.filter(m => m.stage === stage && m.type === type).map(m => m.message); } -function buildBasePayload(group: TaskletGroup, participant: ParticipantContext, submittedAt: string): BaseTaskletPayload | null { +function buildBasePayload( + group: TaskletGroup, + participant: ParticipantContext, + submittedAt: string, + lineHistory: Map> +): BaseTaskletPayload | null { const generatedAt = generatedAtIso(group); if (generatedAt === null) { return null; @@ -184,30 +267,27 @@ function buildBasePayload(group: TaskletGroup, participant: ParticipantContext, ownership_flip: group.files.some(f => f.ghostLines.length > 0), bleu_score: averageBleuScore(group.files), review_latency_sec: reviewLatencySec(group), + history_tasklet_ids: buildHistoryTaskletIds(group, lineHistory), }; } export function buildTaskletResearchPayloads( history: History, - consentTier: 1 | 2 | 3, + consentTier: 1 | 2, participant: ParticipantContext, submittedAt: string = new Date().toISOString() ): TaskletResearchPayload[] { const groups = groupByTasklet(history); + const lineHistory = buildLineHistory(history, buildLineOwnership(history)); const payloads: TaskletResearchPayload[] = []; for (const group of groups) { - const base = buildBasePayload(group, participant, submittedAt); + const base = buildBasePayload(group, participant, submittedAt, lineHistory); if (!base) { continue; } - if (consentTier === 1) { - payloads.push({ ...base, consent_level: 1 }); - continue; - } - - const tier2Fields = { + const tier1Fields = { plan_prompts: messagesByStage(group.messages, "plan", "prompt").map(redactSensitiveText), plan_responses: messagesByStage(group.messages, "plan", "response").map(redactSensitiveText), build_prompt: redactSensitiveText(messagesByStage(group.messages, "build", "prompt")[0] ?? ""), @@ -218,8 +298,8 @@ export function buildTaskletResearchPayloads( })), }; - if (consentTier === 2) { - payloads.push({ ...base, ...tier2Fields, consent_level: 2 }); + if (consentTier === 1) { + payloads.push({ ...base, ...tier1Fields, consent_level: 1 }); continue; } @@ -238,8 +318,8 @@ export function buildTaskletResearchPayloads( payloads.push({ ...base, - ...tier2Fields, - consent_level: 3, + ...tier1Fields, + consent_level: 2, diff_hunks: diffHunks, hunk_significance: hunkSignificance, }); diff --git a/vscode-extension/src/research/collectorRepo.test.ts b/vscode-extension/src/research/collectorRepo.test.ts index f36fe43..0407ac5 100644 --- a/vscode-extension/src/research/collectorRepo.test.ts +++ b/vscode-extension/src/research/collectorRepo.test.ts @@ -22,7 +22,13 @@ function makePayload(overrides: Partial = {}): Tier1Payload { 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, }; } diff --git a/vscode-extension/src/research/consent.ts b/vscode-extension/src/research/consent.ts index d9aff01..dfd5d80 100644 --- a/vscode-extension/src/research/consent.ts +++ b/vscode-extension/src/research/consent.ts @@ -1,12 +1,13 @@ import * as vscode from 'vscode'; import { randomUUID } from 'crypto'; import { ParticipantContext } from './types'; -import { getConsentTierForRepo, getRepoUrlForRepo, isResearchModeEnabledForRepo } from './repoConsent'; +import { getConsentTierForRepo, isResearchModeEnabledForRepo } from './repoConsent'; +import { getRemoteUrl } from './gitRemote'; const PARTICIPANT_ID_KEY = 'tracybot.researchMode.participantId'; -// Enabled/tier/repoUrl are per-repository (see repoConsent.ts) — re-exported -// here so callers only need one import for "am I collecting, and how much". +// Enabled/tier are per-repository (see repoConsent.ts) — re-exported here so +// callers only need one import for "am I collecting, and how much". export const isResearchModeEnabled = isResearchModeEnabledForRepo; export const getConsentTier = getConsentTierForRepo; @@ -24,9 +25,9 @@ export function getOrCreateParticipantId(context: vscode.ExtensionContext): stri return generated; } -export function getParticipantContext(context: vscode.ExtensionContext, repoPath: string): ParticipantContext { +export async function getParticipantContext(context: vscode.ExtensionContext, repoPath: string): Promise { return { participantId: getOrCreateParticipantId(context), - repoUrl: getRepoUrlForRepo(repoPath), + repoUrl: await getRemoteUrl(repoPath), }; } diff --git a/vscode-extension/src/research/gitRemote.test.ts b/vscode-extension/src/research/gitRemote.test.ts new file mode 100644 index 0000000..a27d6e9 --- /dev/null +++ b/vscode-extension/src/research/gitRemote.test.ts @@ -0,0 +1,40 @@ +import { test, describe } from "node:test"; +import assert from "node:assert/strict"; +import { execSync } from "child_process"; +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; +import { getRemoteUrl } from "./gitRemote"; + +function makeRepo(): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "tracybot-git-remote-")); + execSync("git init -q", { cwd: dir }); + return dir; +} + +describe("getRemoteUrl", () => { + test("returns the configured origin URL", async () => { + const repo = makeRepo(); + execSync("git remote add origin https://github.com/TracyTeam/tracybot.git", { cwd: repo }); + + assert.equal(await getRemoteUrl(repo), "https://github.com/TracyTeam/tracybot.git"); + }); + + test("returns null when no remote is configured", async () => { + const repo = makeRepo(); + assert.equal(await getRemoteUrl(repo), null); + }); + + test("returns null for a path that isn't a git repo", async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "tracybot-not-a-repo-")); + assert.equal(await getRemoteUrl(dir), null); + }); + + test("supports a non-default remote name", async () => { + const repo = makeRepo(); + execSync("git remote add upstream https://github.com/example/upstream.git", { cwd: repo }); + + assert.equal(await getRemoteUrl(repo, "upstream"), "https://github.com/example/upstream.git"); + assert.equal(await getRemoteUrl(repo, "origin"), null); + }); +}); diff --git a/vscode-extension/src/research/gitRemote.ts b/vscode-extension/src/research/gitRemote.ts new file mode 100644 index 0000000..84c5c4e --- /dev/null +++ b/vscode-extension/src/research/gitRemote.ts @@ -0,0 +1,20 @@ +import { spawn } from "child_process"; + +// Deliberately not routed through utils.ts's runGit — that file imports +// vscode at module scope, which would drag this (and its unit tests) into +// needing the extension host just to load. +// +// repo_url is derived live from the actual git remote rather than something +// the participant types in: for an open-source repo the URL doesn't tell us +// anything we couldn't already find, and for a private one the URL alone +// doesn't grant access either way — so there's no separate consent gate for it. +export function getRemoteUrl(repoPath: string, remoteName: string = "origin"): Promise { + return new Promise((resolve) => { + const proc = spawn("git", ["-C", repoPath, "remote", "get-url", remoteName]); + let stdout = ""; + + proc.stdout.on("data", (data) => { stdout += data.toString(); }); + proc.on("close", (code) => { resolve(code === 0 ? (stdout.trim() || null) : null); }); + proc.on("error", () => resolve(null)); + }); +} diff --git a/vscode-extension/src/research/queue.test.ts b/vscode-extension/src/research/queue.test.ts index 3662116..a817d27 100644 --- a/vscode-extension/src/research/queue.test.ts +++ b/vscode-extension/src/research/queue.test.ts @@ -41,7 +41,13 @@ function makePayload(taskletId: string): Tier1Payload { 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: [], }; } diff --git a/vscode-extension/src/research/repoConsent.test.ts b/vscode-extension/src/research/repoConsent.test.ts index f2ba084..4368ede 100644 --- a/vscode-extension/src/research/repoConsent.test.ts +++ b/vscode-extension/src/research/repoConsent.test.ts @@ -8,7 +8,6 @@ import { writeRepoConsent, isResearchModeEnabledForRepo, getConsentTierForRepo, - getRepoUrlForRepo, } from "./repoConsent"; function makeRepo(): string { @@ -21,7 +20,6 @@ describe("repoConsent", () => { assert.equal(readRepoConsent(repo), undefined); assert.equal(isResearchModeEnabledForRepo(repo), false); assert.equal(getConsentTierForRepo(repo), 1); - assert.equal(getRepoUrlForRepo(repo), null); }); test("writeRepoConsent persists an enabled decision under .git/tracybot/", () => { @@ -45,23 +43,13 @@ describe("repoConsent", () => { test("consent for one repo does not leak into a sibling repo", () => { const repoA = makeRepo(); const repoB = makeRepo(); - writeRepoConsent(repoA, { decision: "enabled", consentTier: 3 }); + writeRepoConsent(repoA, { decision: "enabled", consentTier: 2 }); assert.equal(isResearchModeEnabledForRepo(repoA), true); assert.equal(isResearchModeEnabledForRepo(repoB), false); assert.equal(readRepoConsent(repoB), undefined); }); - test("getRepoUrlForRepo returns the stored URL only when enabled and set", () => { - const repo = makeRepo(); - writeRepoConsent(repo, { decision: "enabled", consentTier: 1, repoUrl: "https://example.com/repo" }); - assert.equal(getRepoUrlForRepo(repo), "https://example.com/repo"); - - const declinedRepo = makeRepo(); - writeRepoConsent(declinedRepo, { decision: "declined" }); - assert.equal(getRepoUrlForRepo(declinedRepo), null); - }); - test("a corrupted consent file is treated as undecided, not a crash", () => { const repo = makeRepo(); const filePath = path.join(repo, ".git", "tracybot", "research-consent.json"); diff --git a/vscode-extension/src/research/repoConsent.ts b/vscode-extension/src/research/repoConsent.ts index 32f10be..947a33e 100644 --- a/vscode-extension/src/research/repoConsent.ts +++ b/vscode-extension/src/research/repoConsent.ts @@ -9,7 +9,7 @@ import * as path from 'path'; // never risks ending up committed and pushed to a shared remote the way a // workspace-level Setting in .vscode/settings.json could. export type RepoConsent = - | { decision: 'enabled'; consentTier: 1 | 2 | 3; repoUrl?: string } + | { decision: 'enabled'; consentTier: 1 | 2 } | { decision: 'declined' }; function consentFilePath(repoPath: string): string { @@ -37,12 +37,7 @@ export function isResearchModeEnabledForRepo(repoPath: string): boolean { return readRepoConsent(repoPath)?.decision === 'enabled'; } -export function getConsentTierForRepo(repoPath: string): 1 | 2 | 3 { +export function getConsentTierForRepo(repoPath: string): 1 | 2 { const consent = readRepoConsent(repoPath); return consent?.decision === 'enabled' ? consent.consentTier : 1; } - -export function getRepoUrlForRepo(repoPath: string): string | null { - const consent = readRepoConsent(repoPath); - return (consent?.decision === 'enabled' && consent.repoUrl) || null; -} diff --git a/vscode-extension/src/research/researchModeCheck.ts b/vscode-extension/src/research/researchModeCheck.ts index f3c2df7..f2a0aec 100644 --- a/vscode-extension/src/research/researchModeCheck.ts +++ b/vscode-extension/src/research/researchModeCheck.ts @@ -4,7 +4,7 @@ import { getOrCreateParticipantId } from './consent'; import { readRepoConsent, writeRepoConsent } from './repoConsent'; interface TierPickItem extends vscode.QuickPickItem { - tier: 1 | 2 | 3; + tier: 1 | 2; } // Shown inline as part of opting in, not buried in Settings afterward — a @@ -13,18 +13,12 @@ interface TierPickItem extends vscode.QuickPickItem { const TIER_OPTIONS: TierPickItem[] = [ { tier: 1, - label: 'Stats only', - description: 'model, timestamps, line counts', - detail: 'No prompt text, no code is shared.', - }, - { - tier: 2, - label: '+ Prompt and response text', + label: 'Prompt and response text', description: 'fenced code blocks redacted', - detail: 'Also shares what you asked the AI and what it replied.', + detail: 'Shares what you asked the AI and what it replied.', }, { - tier: 3, + tier: 2, label: '+ Code diffs', description: 'only the lines each AI edit touched', detail: 'Also shares the actual code each AI-generated Tasklet changed.', @@ -34,7 +28,7 @@ const TIER_OPTIONS: TierPickItem[] = [ // Exported (rather than exporting TIER_OPTIONS directly) so the Manage // Research Mode command in extension.ts can offer the exact same tier // picker for a Change Tier action, without duplicating how it's presented. -export async function pickResearchModeTier(): Promise<1 | 2 | 3 | undefined> { +export async function pickResearchModeTier(): Promise<1 | 2 | undefined> { const chosen = await vscode.window.showQuickPick(TIER_OPTIONS, { title: 'Tracybot Research Mode — choose how much to share', placeHolder: 'You can change or disable this anytime from the Research Mode status bar item', diff --git a/vscode-extension/src/research/types.ts b/vscode-extension/src/research/types.ts index 9824ead..acbbfa9 100644 --- a/vscode-extension/src/research/types.ts +++ b/vscode-extension/src/research/types.ts @@ -22,13 +22,11 @@ export interface BaseTaskletPayload { ownership_flip: boolean; // true if any Change in this Tasklet has ghostLines bleu_score: number | null; // summary: average of non-null per-hunk BLEU scores; null if none apply review_latency_sec: number | null; // null until the Tasklet's snapshot has been committed -} -export interface Tier1Payload extends BaseTaskletPayload { - consent_level: 1; + history_tasklet_ids: string[]; // IDs of prior Tasklets that previously owned any line this Tasklet currently (live-ly) owns, oldest -> newest, deduped } -interface Tier2Fields { +interface Tier1Fields { plan_prompts: string[]; plan_responses: string[]; // fenced code blocks replaced with "[code omitted]" build_prompt: string; @@ -36,8 +34,8 @@ interface Tier2Fields { questions_answers: { question: string; answer: string[] }[]; } -export interface Tier2Payload extends BaseTaskletPayload, Tier2Fields { - consent_level: 2; +export interface Tier1Payload extends BaseTaskletPayload, Tier1Fields { + consent_level: 1; } export interface ResearchDiffHunk { @@ -50,13 +48,13 @@ export interface ResearchDiffHunk { removed_lines: string[]; } -export interface Tier3Payload extends BaseTaskletPayload, Tier2Fields { - consent_level: 3; +export interface Tier2Payload extends BaseTaskletPayload, Tier1Fields { + consent_level: 2; diff_hunks: ResearchDiffHunk[]; // every hunk touched by this Tasklet, never a full file/repo snapshot hunk_significance: boolean[]; // BLEU-threshold result per hunk, same index order as diff_hunks } -export type TaskletResearchPayload = Tier1Payload | Tier2Payload | Tier3Payload; +export type TaskletResearchPayload = Tier1Payload | Tier2Payload; export interface ParticipantContext { participantId: string;