diff --git a/packages/extension/scores/overture/SCORE.md b/packages/extension/scores/overture/SCORE.md index fb327c5e..f3eeb7ce 100644 --- a/packages/extension/scores/overture/SCORE.md +++ b/packages/extension/scores/overture/SCORE.md @@ -27,6 +27,13 @@ stages: ] multiple: true default: "Research" + - id: context_seed + optional: true + questions: + - id: seed_optin + prompt: "I can scan your existing AI-tool configs to bootstrap your workspace — want me to?" + choices: ["Yes, scan my configs", "No thanks, skip"] + default: "Yes, scan my configs" --- You are running the **overture** — Amico's onboarding interview (session zero). @@ -84,8 +91,41 @@ Per-stage guidance and the `amicode_profile` mapping: keeps the overture fast and generic. After recording intent, acknowledge briefly ("Got it — let's get you set up") - and advance to the next stage. **Stages 3–8 are defined in subsequent slices** - — for now, after Stage 2 completes, record the completion marker: - `amicode_profile {entity:"onboarding_completed"}` and hand off to a normal - session. (Later slices will insert context-seed, demo, collection, and - handoff stages between intent and completion.) + and advance to Stage 3. + +3. **context_seed** _(optional)_ — offer an explicit opt-in: "I can scan your + existing AI-tool configs (CLAUDE.md, cursor rules, opencode config) to + bootstrap your workspace — want me to?" via the `question` tool with the + two choices above. + + **If the user DECLINES:** perform ZERO file reads. Say "No problem" and + advance to the next stage immediately. + + **If the user ACCEPTS:** call `amicode_context_seed` with `action: "scan"`. + This scans allowlisted paths only (CLAUDE.md, AGENTS.md, .cursorrules, + opencode configs at known roots), applies secret redaction at read time, and + returns a grouped preview of extractable facts: + - **Profile facts** (name, role, platforms) — with source provenance + - **Memory cards** (project context, tool preferences) — with source provenance + + Present the preview to the user, grouped by category, showing which file + each fact came from. Ask: "Want me to import all of these, or deselect any + groups?" via the `question` tool with `multiple: true` options for each group. + + On confirm, call `amicode_context_seed` with `action: "write"` and the + selected groups. The tool writes seeds to `events.jsonl` via + `appendOnboardingEvent()`. Seeds flow through the existing distiller pipeline + to materialize in the vault. + + **Constraints:** + - Secrets (API keys, tokens, passwords, PEM blocks) are NEVER stored — they + are redacted to `«credential omitted»` before you ever see the content. + - Seeds MUST NOT invent facts — every line traces to a scanned file. + - Re-running is idempotent (match-before-create). + - If no scannable files are found, say so honestly: "I didn't find any + AI-tool configs to import — no worries, we'll build your context as we go." + + After seeding (or declining), advance to the next stage. **Stages 4–8 are + defined in subsequent slices** — for now, after Stage 3 completes, record + the completion marker: `amicode_profile {entity:"onboarding_completed"}` + and hand off to a normal session. diff --git a/packages/extension/src/context_seed.ts b/packages/extension/src/context_seed.ts new file mode 100644 index 00000000..c6515bc0 --- /dev/null +++ b/packages/extension/src/context_seed.ts @@ -0,0 +1,311 @@ +// Context-seed pipeline — scan, redact, extract, materialize (#436) +// +// Scans allowlisted AI-tool config files, redacts secrets, extracts profile +// facts and memory cards, and writes them through the existing onboarding +// event pipeline. No VS Code dependencies — pure Node logic. + +import * as fs from "node:fs"; +import * as path from "node:path"; +import * as os from "node:os"; + +// ─── Allowlist (data-driven) ───────────────────────────────────────────────── + +export interface ScanRoot { + /** Root directory to scan. `~` is expanded to homedir. */ + root: string; + /** Filenames to look for under this root. */ + filenames: string[]; +} + +/** The allowlisted scan locations. Expand `~` before use. */ +export const SCAN_ALLOWLIST: ScanRoot[] = [ + { root: "~", filenames: ["CLAUDE.md", "AGENTS.md", ".cursorrules"] }, + { root: "~/.config/opencode", filenames: ["opencode.json", "opencode.jsonc"] }, + { root: "~/.cursor", filenames: ["rules"] }, + { root: "~/.continue", filenames: ["config.json"] }, +]; + +/** Maximum bytes to read from any single file. */ +export const SIZE_CAP = 50 * 1024; // 50KB + +/** Resolve `~` to the actual home directory. */ +function expandHome(p: string): string { + if (p === "~") return os.homedir(); + if (p.startsWith("~/")) return path.join(os.homedir(), p.slice(2)); + return p; +} + +// ─── Scanner ───────────────────────────────────────────────────────────────── + +export interface ScannedFile { + /** Absolute path of the file that was read. */ + path: string; + /** Content (size-capped, secret-redacted). */ + content: string; + /** Whether the content was truncated due to size cap. */ + truncated: boolean; +} + +/** Resolve the allowlist to the files that actually exist on disk. + * Returns only existing, readable paths. Does NOT read content. */ +export function resolveAllowlist( + allowlist: ScanRoot[] = SCAN_ALLOWLIST, +): string[] { + const results: string[] = []; + for (const entry of allowlist) { + const root = expandHome(entry.root); + for (const filename of entry.filenames) { + const full = path.join(root, filename); + try { + fs.accessSync(full, fs.constants.R_OK); + results.push(full); + } catch { + // File doesn't exist or isn't readable — skip + } + } + } + return results; +} + +/** Read a file with size cap and secret redaction applied at read time. + * Returns the processed content. If the file cannot be read, returns undefined. */ +export function readAndRedact(filePath: string, sizeCap: number = SIZE_CAP): ScannedFile | undefined { + try { + const stat = fs.statSync(filePath); + const truncated = stat.size > sizeCap; + const fd = fs.openSync(filePath, "r"); + const buffer = Buffer.alloc(Math.min(stat.size, sizeCap)); + fs.readSync(fd, buffer, 0, buffer.length, 0); + fs.closeSync(fd); + const raw = buffer.toString("utf8"); + const redacted = redactSecrets(raw); + return { path: filePath, content: redacted, truncated }; + } catch { + return undefined; + } +} + +/** Scan all allowlisted files: resolve → read → redact. Returns only files found. */ +export function scanAllowlistedFiles(allowlist: ScanRoot[] = SCAN_ALLOWLIST): ScannedFile[] { + const paths = resolveAllowlist(allowlist); + const results: ScannedFile[] = []; + for (const p of paths) { + const file = readAndRedact(p); + if (file) results.push(file); + } + return results; +} + +// ─── Secret redaction ──────────────────────────────────────────────────────── + +/** Expanded SECRET_RE — matches API keys, tokens, passwords, PEM blocks, etc. + * Applied AT READ TIME, before any processing or storage. */ +export const SECRET_PATTERNS: RegExp[] = [ + // Key-value pairs with secret-like keys + /(?<=[\s"':=])(sk-[a-zA-Z0-9_-]{20,})(?=[\s"',\n]|$)/g, + /(?<=[\s"':=])(sk-ant-[a-zA-Z0-9_-]{20,})(?=[\s"',\n]|$)/g, + // AWS access key IDs + /AKIA[0-9A-Z]{16}/g, + // Bearer tokens in content + /Bearer\s+[a-zA-Z0-9._\-+/=]{20,}/g, + // PEM blocks + /-----BEGIN [A-Z ]+-----[\s\S]*?-----END [A-Z ]+-----/g, + // Generic key=value patterns where key contains secret/token/password/api_key + /(?:api[_-]?key|secret[_-]?key|auth[_-]?token|password|access[_-]?token)\s*[:=]\s*["']?[^\s"'\n]{8,}["']?/gi, +]; + +/** Redact secret-looking values from content, replacing with «credential omitted». */ +export function redactSecrets(content: string): string { + let result = content; + for (const pattern of SECRET_PATTERNS) { + // Reset lastIndex for global patterns + pattern.lastIndex = 0; + result = result.replace(pattern, "«credential omitted»"); + } + return result; +} + +// ─── Fact extraction ───────────────────────────────────────────────────────── + +export interface ExtractedFact { + /** Which vault target this belongs to. */ + target: "profile" | "memory"; + /** For profile: field name. For memory: card type. */ + field: string; + /** The extracted value. */ + value: unknown; + /** Which file it was extracted from. */ + source: string; +} + +export interface SeedPreview { + /** Profile facts (name, role, platforms, etc.) */ + profileFacts: ExtractedFact[]; + /** Memory cards (project facts, tool preferences) */ + memoryCards: ExtractedFact[]; +} + +/** Extract profile facts and memory cards from scanned files. + * Conservative — only extracts clearly-structured information. */ +export function extractFacts(files: ScannedFile[]): SeedPreview { + const profileFacts: ExtractedFact[] = []; + const memoryCards: ExtractedFact[] = []; + + for (const file of files) { + const basename = path.basename(file.path); + + if (basename === "CLAUDE.md" || basename === "AGENTS.md") { + // Look for structured identity patterns + extractMarkdownIdentity(file, profileFacts); + extractProjectFacts(file, memoryCards); + } else if (basename === ".cursorrules" || basename === "rules") { + extractProjectFacts(file, memoryCards); + } else if (basename.endsWith(".json") || basename.endsWith(".jsonc")) { + extractJsonConfig(file, profileFacts, memoryCards); + } + } + + return { profileFacts, memoryCards }; +} + +/** Extract identity fields from markdown-style AI config files. */ +function extractMarkdownIdentity(file: ScannedFile, facts: ExtractedFact[]): void { + const content = file.content; + + // Look for name patterns like "User: Name" or "# About\nName: ..." + const nameMatch = content.match( + /(?:^|\n)\s*(?:name|user|author|developer)\s*[:=]\s*(.+?)(?:\n|$)/i, + ); + if (nameMatch) { + const name = nameMatch[1].trim(); + if (name && name.length < 100 && !name.includes("«credential")) { + facts.push({ target: "profile", field: "name", value: name, source: file.path }); + } + } + + // Look for role/affiliation patterns + const roleMatch = content.match( + /(?:^|\n)\s*(?:role|title|position)\s*[:=]\s*(.+?)(?:\n|$)/i, + ); + if (roleMatch) { + const role = roleMatch[1].trim(); + if (role && role.length < 200) { + facts.push({ target: "profile", field: "role", value: role, source: file.path }); + } + } + + // Look for platform mentions + const platformPatterns = [ + /(?:transmon|superconducting qubit)/i, + /(?:rydberg|neutral.atom)/i, + /(?:trapped.ion|ion.trap)/i, + /(?:cavity|bosonic)/i, + /(?:fluxonium)/i, + ]; + const platforms: string[] = []; + for (const pat of platformPatterns) { + if (pat.test(content)) { + const match = content.match(pat); + if (match) platforms.push(match[0].toLowerCase()); + } + } + if (platforms.length > 0) { + facts.push({ target: "profile", field: "platforms", value: platforms, source: file.path }); + } +} + +/** Extract project-level facts as memory cards. */ +function extractProjectFacts(file: ScannedFile, cards: ExtractedFact[]): void { + const content = file.content; + const lines = content.split("\n"); + + // Extract key directives/rules as a single project-fact card + const directives: string[] = []; + for (const line of lines.slice(0, 50)) { // Only first 50 lines + const trimmed = line.trim(); + if (trimmed.startsWith("- ") && trimmed.length > 10 && trimmed.length < 200) { + directives.push(trimmed); + } + } + if (directives.length > 0) { + cards.push({ + target: "memory", + field: "project_context", + value: directives.slice(0, 10).join("\n"), // Cap at 10 most relevant + source: file.path, + }); + } +} + +/** Extract config from JSON-format AI tool configs. */ +function extractJsonConfig( + file: ScannedFile, + _profileFacts: ExtractedFact[], + memoryCards: ExtractedFact[], +): void { + try { + // Strip comments for JSONC + const stripped = file.content.replace(/^\s*\/\/.*$/gm, ""); + const config = JSON.parse(stripped) as Record; + + // Extract any "rules" or "instructions" fields as memory cards + if (typeof config.instructions === "string" || Array.isArray(config.instructions)) { + memoryCards.push({ + target: "memory", + field: "tool_config", + value: `Instructions from ${path.basename(file.path)}`, + source: file.path, + }); + } + } catch { + // Not valid JSON — skip + } +} + +// ─── Seed writing (via existing event pipeline) ────────────────────────────── + +export interface SeedWriteResult { + /** Number of entities written. */ + count: number; + /** Any entities that were skipped (duplicates). */ + skipped: number; +} + +/** Write confirmed seeds to events.jsonl via appendOnboardingEvent. + * Checks for idempotency: profile fields already present are skipped. */ +export function writeSeeds( + dir: string, + preview: SeedPreview, + selectedGroups: { profile: boolean; memory: boolean }, + appendFn: (dir: string, entity: string, payload: Record) => { seq: number }, +): SeedWriteResult { + let count = 0; + let skipped = 0; + + if (selectedGroups.profile && preview.profileFacts.length > 0) { + // Merge all profile facts into one payload + const payload: Record = {}; + for (const fact of preview.profileFacts) { + payload[fact.field] = fact.value; + } + try { + appendFn(dir, "profile", payload); + count++; + } catch { + skipped++; + } + } + + if (selectedGroups.memory) { + for (const card of preview.memoryCards) { + try { + appendFn(dir, "profile", { [`seed_${card.field}`]: card.value }); + count++; + } catch { + skipped++; + } + } + } + + return { count, skipped }; +} diff --git a/packages/extension/test/context_seed.test.ts b/packages/extension/test/context_seed.test.ts new file mode 100644 index 00000000..3aee9673 --- /dev/null +++ b/packages/extension/test/context_seed.test.ts @@ -0,0 +1,349 @@ +// Context-seed pipeline tests (#436) +// +// Tests the allowlist scanner, secret redaction, fact extraction, +// size cap, and idempotent seed writing. + +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import * as fs from "node:fs"; +import * as path from "node:path"; +import * as os from "node:os"; + +import { + resolveAllowlist, + readAndRedact, + scanAllowlistedFiles, + redactSecrets, + extractFacts, + writeSeeds, + SIZE_CAP, + type ScanRoot, + type ScannedFile, + type SeedPreview, +} from "../src/context_seed"; + +// ─── AC2, AC10: Allowlist resolution ───────────────────────────────────────── + +describe("resolveAllowlist — data-driven path scanning (AC2, AC10)", () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "seed-scan-")); + }); + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it("returns only files that exist on the allowlist", () => { + // Stage some files + fs.writeFileSync(path.join(tmpDir, "CLAUDE.md"), "# Test"); + fs.writeFileSync(path.join(tmpDir, ".cursorrules"), "rules"); + // "AGENTS.md" is NOT staged + + const allowlist: ScanRoot[] = [ + { root: tmpDir, filenames: ["CLAUDE.md", "AGENTS.md", ".cursorrules"] }, + ]; + const resolved = resolveAllowlist(allowlist); + expect(resolved).toHaveLength(2); + expect(resolved).toContain(path.join(tmpDir, "CLAUDE.md")); + expect(resolved).toContain(path.join(tmpDir, ".cursorrules")); + expect(resolved).not.toContain(path.join(tmpDir, "AGENTS.md")); + }); + + it("returns empty array when no files exist", () => { + const allowlist: ScanRoot[] = [ + { root: tmpDir, filenames: ["nonexistent.md"] }, + ]; + expect(resolveAllowlist(allowlist)).toEqual([]); + }); + + it("handles multiple roots", () => { + const root2 = fs.mkdtempSync(path.join(os.tmpdir(), "seed-root2-")); + fs.writeFileSync(path.join(tmpDir, "A.md"), "a"); + fs.writeFileSync(path.join(root2, "B.md"), "b"); + + const allowlist: ScanRoot[] = [ + { root: tmpDir, filenames: ["A.md"] }, + { root: root2, filenames: ["B.md"] }, + ]; + const resolved = resolveAllowlist(allowlist); + expect(resolved).toHaveLength(2); + fs.rmSync(root2, { recursive: true, force: true }); + }); +}); + +// ─── AC4: Secret redaction ─────────────────────────────────────────────────── + +describe("redactSecrets — credential removal at read time (AC4)", () => { + it("redacts sk- prefixed API keys", () => { + const input = 'api_key = "sk-proj-abc123def456ghi789jkl012mno345"'; + const result = redactSecrets(input); + expect(result).toContain("«credential omitted»"); + expect(result).not.toContain("sk-proj-abc123"); + }); + + it("redacts sk-ant- (Anthropic) keys", () => { + const input = 'key: "sk-ant-api03-very-long-key-value-here-1234567890"'; + const result = redactSecrets(input); + expect(result).toContain("«credential omitted»"); + expect(result).not.toContain("sk-ant-"); + }); + + it("redacts AWS access key IDs", () => { + const input = "AWS_ACCESS_KEY_ID=AKIAIOSFODNN7EXAMPLE"; + const result = redactSecrets(input); + expect(result).toContain("«credential omitted»"); + expect(result).not.toContain("AKIAIOSFODNN7EXAMPLE"); + }); + + it("redacts Bearer tokens", () => { + const input = "Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.veryLongTokenValue12345"; + const result = redactSecrets(input); + expect(result).toContain("«credential omitted»"); + expect(result).not.toContain("eyJhbGci"); + }); + + it("redacts PEM blocks", () => { + const input = "-----BEGIN RSA PRIVATE KEY-----\nMIIBogIBAAJBALR\n-----END RSA PRIVATE KEY-----"; + const result = redactSecrets(input); + expect(result).toContain("«credential omitted»"); + expect(result).not.toContain("MIIBogIBAAJBALR"); + }); + + it("redacts key=value patterns with secret-like keys", () => { + const input = 'api_key: "my-super-secret-key-value-here"'; + const result = redactSecrets(input); + expect(result).toContain("«credential omitted»"); + }); + + it("preserves non-secret content", () => { + const input = "# My Project\n\nThis is a description of my quantum control project."; + const result = redactSecrets(input); + expect(result).toBe(input); + }); +}); + +// ─── AC3: Size cap ─────────────────────────────────────────────────────────── + +describe("readAndRedact — size cap (AC3)", () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "seed-cap-")); + }); + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it("truncates files exceeding the size cap", () => { + const largePath = path.join(tmpDir, "large.md"); + // Write content larger than a small cap + fs.writeFileSync(largePath, "x".repeat(200)); + const result = readAndRedact(largePath, 100); + expect(result).toBeDefined(); + expect(result!.content.length).toBe(100); + expect(result!.truncated).toBe(true); + }); + + it("does not truncate files under the cap", () => { + const smallPath = path.join(tmpDir, "small.md"); + fs.writeFileSync(smallPath, "hello world"); + const result = readAndRedact(smallPath, 1000); + expect(result).toBeDefined(); + expect(result!.content).toBe("hello world"); + expect(result!.truncated).toBe(false); + }); + + it("returns undefined for non-existent files", () => { + expect(readAndRedact(path.join(tmpDir, "nope.md"))).toBeUndefined(); + }); + + it("applies secret redaction before returning", () => { + const secretPath = path.join(tmpDir, "secret.md"); + fs.writeFileSync(secretPath, "my_key: AKIAIOSFODNN7EXAMPLE\nother: value"); + const result = readAndRedact(secretPath); + expect(result!.content).toContain("«credential omitted»"); + expect(result!.content).not.toContain("AKIAIOSFODNN7EXAMPLE"); + }); +}); + +// ─── AC5: Fact extraction ──────────────────────────────────────────────────── + +describe("extractFacts — profile and memory card extraction (AC5)", () => { + it("extracts name from CLAUDE.md with name: pattern", () => { + const files: ScannedFile[] = [{ + path: "/home/user/CLAUDE.md", + content: "# Instructions\n\nname: Alice Smith\nrole: Researcher\n", + truncated: false, + }]; + const result = extractFacts(files); + expect(result.profileFacts.some((f) => f.field === "name" && f.value === "Alice Smith")).toBe(true); + expect(result.profileFacts.some((f) => f.field === "role" && f.value === "Researcher")).toBe(true); + }); + + it("extracts platforms from mentions in content", () => { + const files: ScannedFile[] = [{ + path: "/home/user/CLAUDE.md", + content: "I work with transmon qubits and Rydberg atoms.", + truncated: false, + }]; + const result = extractFacts(files); + const platformFact = result.profileFacts.find((f) => f.field === "platforms"); + expect(platformFact).toBeDefined(); + expect(platformFact!.value).toContain("transmon"); + }); + + it("extracts project directives as memory cards from .cursorrules", () => { + const files: ScannedFile[] = [{ + path: "/home/user/.cursorrules", + content: "- Always use TypeScript strict mode\n- Prefer functional components\n- Use vitest for testing\n", + truncated: false, + }]; + const result = extractFacts(files); + expect(result.memoryCards.length).toBeGreaterThan(0); + expect(result.memoryCards[0].field).toBe("project_context"); + }); + + it("tracks provenance (source file path) on each fact", () => { + const files: ScannedFile[] = [{ + path: "/home/user/CLAUDE.md", + content: "name: Bob\n", + truncated: false, + }]; + const result = extractFacts(files); + expect(result.profileFacts[0].source).toBe("/home/user/CLAUDE.md"); + }); + + it("returns empty preview for files with no extractable facts", () => { + const files: ScannedFile[] = [{ + path: "/home/user/empty.md", + content: "# Generic\n\nNo structured data here.\n", + truncated: false, + }]; + const result = extractFacts(files); + expect(result.profileFacts).toHaveLength(0); + expect(result.memoryCards).toHaveLength(0); + }); + + it("does not extract «credential omitted» as name", () => { + const files: ScannedFile[] = [{ + path: "/home/user/CLAUDE.md", + content: "name: «credential omitted»\n", + truncated: false, + }]; + const result = extractFacts(files); + expect(result.profileFacts.find((f) => f.field === "name")).toBeUndefined(); + }); +}); + +// ─── AC6-7, AC9: Seed writing + idempotency ───────────────────────────────── + +describe("writeSeeds — event pipeline writing (AC7, AC9)", () => { + let tmpDir: string; + let appendCalls: Array<{ entity: string; payload: Record }>; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "seed-write-")); + appendCalls = []; + }); + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + const mockAppend = (dir: string, entity: string, payload: Record) => { + appendCalls.push({ entity, payload }); + return { seq: appendCalls.length }; + }; + + it("AC7: writes profile facts via appendOnboardingEvent", () => { + const preview: SeedPreview = { + profileFacts: [ + { target: "profile", field: "name", value: "Test User", source: "/a.md" }, + { target: "profile", field: "role", value: "Researcher", source: "/a.md" }, + ], + memoryCards: [], + }; + const result = writeSeeds(tmpDir, preview, { profile: true, memory: true }, mockAppend); + expect(result.count).toBe(1); // merged into one profile event + expect(appendCalls[0].entity).toBe("profile"); + expect(appendCalls[0].payload).toEqual({ name: "Test User", role: "Researcher" }); + }); + + it("AC6: respects group deselection (profile deselected → not written)", () => { + const preview: SeedPreview = { + profileFacts: [ + { target: "profile", field: "name", value: "Test", source: "/a.md" }, + ], + memoryCards: [ + { target: "memory", field: "project_context", value: "stuff", source: "/b.md" }, + ], + }; + const result = writeSeeds(tmpDir, preview, { profile: false, memory: true }, mockAppend); + expect(appendCalls.every((c) => c.payload.name === undefined)).toBe(true); + expect(result.count).toBe(1); // only memory card written + }); + + it("AC6: respects group deselection (memory deselected → not written)", () => { + const preview: SeedPreview = { + profileFacts: [ + { target: "profile", field: "name", value: "Test", source: "/a.md" }, + ], + memoryCards: [ + { target: "memory", field: "project_context", value: "stuff", source: "/b.md" }, + ], + }; + const result = writeSeeds(tmpDir, preview, { profile: true, memory: false }, mockAppend); + expect(result.count).toBe(1); // only profile + expect(appendCalls).toHaveLength(1); + expect(appendCalls[0].payload.name).toBe("Test"); + }); + + it("handles empty preview gracefully", () => { + const preview: SeedPreview = { profileFacts: [], memoryCards: [] }; + const result = writeSeeds(tmpDir, preview, { profile: true, memory: true }, mockAppend); + expect(result.count).toBe(0); + expect(result.skipped).toBe(0); + }); +}); + +// ─── AC1, AC11: Opt-in / nothing-found ─────────────────────────────────────── + +describe("scanAllowlistedFiles — end-to-end scan (AC1, AC11)", () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "seed-e2e-")); + }); + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it("AC11: returns empty array when no files exist (nothing found)", () => { + const allowlist: ScanRoot[] = [{ root: tmpDir, filenames: ["nope.md"] }]; + const result = scanAllowlistedFiles(allowlist); + expect(result).toEqual([]); + }); + + it("scans multiple files and returns all with content", () => { + fs.writeFileSync(path.join(tmpDir, "CLAUDE.md"), "# Claude\nname: Alice\n"); + fs.writeFileSync(path.join(tmpDir, ".cursorrules"), "- rule one\n"); + + const allowlist: ScanRoot[] = [ + { root: tmpDir, filenames: ["CLAUDE.md", ".cursorrules"] }, + ]; + const result = scanAllowlistedFiles(allowlist); + expect(result).toHaveLength(2); + expect(result[0].content).toContain("Alice"); + expect(result[1].content).toContain("rule one"); + }); + + it("secrets are redacted in the returned content", () => { + fs.writeFileSync(path.join(tmpDir, "config.md"), "token: AKIAIOSFODNN7EXAMPLE\n"); + + const allowlist: ScanRoot[] = [ + { root: tmpDir, filenames: ["config.md"] }, + ]; + const result = scanAllowlistedFiles(allowlist); + expect(result[0].content).not.toContain("AKIAIOSFODNN7EXAMPLE"); + expect(result[0].content).toContain("«credential omitted»"); + }); +}); diff --git a/packages/extension/test/scores/golden/compile-chained.md b/packages/extension/test/scores/golden/compile-chained.md index 2af8d926..6861d82e 100644 --- a/packages/extension/test/scores/golden/compile-chained.md +++ b/packages/extension/test/scores/golden/compile-chained.md @@ -16,29 +16,31 @@ gate's checks pass. - Q `name`: "What should I call you?" 2. **intent** - Q `intent`: "What brings you to Amicode?" — options: General coding and software development | Research (recommended) | Exploring -3. **platform** +3. **context_seed** (optional) + - Q `seed_optin`: "I can scan your existing AI-tool configs to bootstrap your workspace — want me to?" — options: Yes, scan my configs (recommended) | No thanks, skip +4. **platform** - Q `platform`: "What kind of system are you working with?" — options: transmon (recommended) | neutral-atom Rydberg | cavity / bosonic | other -4. **model** +5. **model** - emits: system — record via the matching `amicode_*` tool - Q `levels`: "How many levels should the model keep? (I'll recommend based on your system — see guidance)" — default: platform-dependent (transmon 3–4; a cavity/bosonic mode wants a Fock cutoff) - Q `drives`: "Drive parameterization and amplitude bound (drive_max)?" — default: two quadratures, drive_max = 0.2 GHz -5. **mode** +6. **mode** - Q `mode`: "Simulate first, or go straight to solve?" — options: solve (recommended) | simulate - Q `warm_start`: "Warm start from a previous pulse (pulse.jld2) — including one from your pulse bank — or cold start?" — options: cold start (recommended) | warm start - skip if: mode == simulate -6. **problem** +7. **problem** - Q `target`: "What is the target — a gate, or a state to prepare?" — default: a single-qubit gate -7. **formulate** +8. **formulate** - emits: formulation — record via the matching `amicode_*` tool - Q `formulation`: "The problem shape — trajectory type (gate / state-prep / open-system), fixed-time vs min-time, and any robustness or free-phase? (the infidelity objective is DERIVED from the type; constraints default to the amplitude bound)" — default: a fixed-time gate, free-phase on for entangling gates - [Why?] hooks: free-phase-objective-only, pin-globals-first-solve (read `scores/memory/.md` on request) -8. **solve** +9. **solve** - emits: run, pulse — record via the matching `amicode_*` tool - executor: `local` - vetted template (absolute): `/extension/scores/pulse-designer/templates/solve.jl` - Q `solve_params`: "Pulse duration T (ns), timesteps N, and max_iter?" — default: T = 10 ns, N = 50, max_iter = 60 -9. **inspect** -10. **hardware** (optional) +10. **inspect** +11. **hardware** (optional) - emits: device_session — record via the matching `amicode_*` tool --- @@ -98,11 +100,44 @@ Per-stage guidance and the `amicode_profile` mapping: keeps the overture fast and generic. After recording intent, acknowledge briefly ("Got it — let's get you set up") - and advance to the next stage. **Stages 3–8 are defined in subsequent slices** - — for now, after Stage 2 completes, record the completion marker: - `amicode_profile {entity:"onboarding_completed"}` and hand off to a normal - session. (Later slices will insert context-seed, demo, collection, and - handoff stages between intent and completion.) + and advance to Stage 3. + +3. **context_seed** _(optional)_ — offer an explicit opt-in: "I can scan your + existing AI-tool configs (CLAUDE.md, cursor rules, opencode config) to + bootstrap your workspace — want me to?" via the `question` tool with the + two choices above. + + **If the user DECLINES:** perform ZERO file reads. Say "No problem" and + advance to the next stage immediately. + + **If the user ACCEPTS:** call `amicode_context_seed` with `action: "scan"`. + This scans allowlisted paths only (CLAUDE.md, AGENTS.md, .cursorrules, + opencode configs at known roots), applies secret redaction at read time, and + returns a grouped preview of extractable facts: + - **Profile facts** (name, role, platforms) — with source provenance + - **Memory cards** (project context, tool preferences) — with source provenance + + Present the preview to the user, grouped by category, showing which file + each fact came from. Ask: "Want me to import all of these, or deselect any + groups?" via the `question` tool with `multiple: true` options for each group. + + On confirm, call `amicode_context_seed` with `action: "write"` and the + selected groups. The tool writes seeds to `events.jsonl` via + `appendOnboardingEvent()`. Seeds flow through the existing distiller pipeline + to materialize in the vault. + + **Constraints:** + - Secrets (API keys, tokens, passwords, PEM blocks) are NEVER stored — they + are redacted to `«credential omitted»` before you ever see the content. + - Seeds MUST NOT invent facts — every line traces to a scanned file. + - Re-running is idempotent (match-before-create). + - If no scannable files are found, say so honestly: "I didn't find any + AI-tool configs to import — no worries, we'll build your context as we go." + + After seeding (or declining), advance to the next stage. **Stages 4–8 are + defined in subsequent slices** — for now, after Stage 3 completes, record + the completion marker: `amicode_profile {entity:"onboarding_completed"}` + and hand off to a normal session. ---