diff --git a/.gitignore b/.gitignore index d3e15b1..7378666 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,7 @@ node_modules/ out/ *.vsix +.vscode-test/ + +# E2E throwaway sidecars (the harness copies the fixture to a tmpdir, but guard anyway) +test/e2e/fixtures/workspace/.threads/ diff --git a/README.md b/README.md index 2939936..ffb372d 100644 --- a/README.md +++ b/README.md @@ -25,8 +25,28 @@ catalog (a pure, key-free SDK call) in a notification and the 3. Press **F5** (or Run → "Run Extension") to launch the Extension Development Host. 4. In the new window: **Cmd/Ctrl+Shift+P** → **"Cowriting: Show Cline SDK Info"**. +## F2 — Region-anchored threads (Feature #4) + +Attach durable, region-anchored discussion threads to any document. Threads +render in the native VS Code **Comments** gutter and persist as git-native +sidecars under `.threads/.json` (plain, diffable JSON — no server). + +- **Create:** select text → run **"Cowriting: Add Coauthoring Thread on Selection"** + (or the Comments gutter "+"). +- **Reply / Resolve:** use the native Comments reply box and the thread's + Resolve/Reopen actions. +- **Survives edits, reload, and external change (`git pull`):** a hybrid anchor + (durable content fingerprint + live offset tracking) re-resolves the thread. + If the anchored text can't be confidently re-found, the thread is shown as + **orphaned** at its last-known line — never silently moved. + +Design: `vscode-cowriting-plugin-content/specs/coauthoring-inner-loop.md`. No live +`@cline/sdk` turn and no credentials are involved in F2. + ## Develop - `npm run watch` — rebuild on change. -- `npm test` — run the unit test for the SDK driver. +- `npm test` — vitest unit suite (SDK driver, schema, store, anchorer, thread mutations). +- `npm run test:e2e` — `@vscode/test-electron` host E2E + (create → reply → resolve → persist → reload → re-anchor → orphan). - `npm run typecheck` — type-check without emit. diff --git a/docs/superpowers/plans/2026-06-10-f2-region-anchored-threads.md b/docs/superpowers/plans/2026-06-10-f2-region-anchored-threads.md new file mode 100644 index 0000000..f7cd854 --- /dev/null +++ b/docs/superpowers/plans/2026-06-10-f2-region-anchored-threads.md @@ -0,0 +1,1584 @@ +# F2 — Region-Anchored Threads Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Deliver F2 (`benstull/vscode-cowriting-plugin#4`) — durable, region-anchored discussion threads in VS Code, persisted as git-native `.threads/` sidecars, rendered through the native Comments API, with hybrid anchoring that survives edits/reload/external change (orphaning rather than guessing) — on the POC skeleton (#2), per spec `vscode-cowriting-plugin-content/specs/coauthoring-inner-loop.md`. + +**Architecture:** Three **vscode-free** units (unit-tested with vitest, mirroring the POC's `cline.ts`) plus one thin editor-facing controller and a vscode-free thread-mutation helper: +- `src/model.ts` — the versioned artifact schema (shared `anchors` + `provenance` primitives, INV-4) and stable serialization. +- `src/store.ts` — `CoauthorStore`: load/save `.threads/` sidecars via Node `fs`, stable formatting (INV-2). +- `src/anchorer.ts` — `buildFingerprint`, the resolution ladder (exact-unique → context-disambiguated → lineHint tiebreak → orphaned, INV-1/INV-3), and live-offset `shift`. +- `src/threadModel.ts` — vscode-free Artifact mutations (`addThread`, `appendMessage`, `setStatus`). +- `src/threadController.ts` — wires Store + Anchorer + threadModel to `vscode.comments` (create-on-selection, reply, resolve, render, reload, FileSystemWatcher re-anchor, orphaned surface). +- `src/extension.ts` — `activate` wires the controller (keeps the POC's `showClineSdkInfo`) and returns a small test-facing API. + +The extension stays a CJS esbuild bundle (`vscode` + `@cline/sdk` external). **No live `@cline/sdk` turn and no credentials** in F2 (INV-5). The Comments surface is native (not a webview) ⇒ E2E uses **`@vscode/test-electron`** (not Playwright), and E2E host tests are **first-class plan tasks** (SLICE-5). + +**Tech Stack:** TypeScript, VS Code Extension API (Comments API), esbuild (extension bundle), vitest (unit), `@vscode/test-electron` + mocha (host E2E), `@cline/sdk@0.0.46` (already linked, untouched by F2). + +--- + +## File Structure + +| File | Responsibility | +| --- | --- | +| `src/model.ts` | Artifact schema types (`Artifact`, `Anchor`, `Fingerprint`, `Thread`, `Message`, `Provenance`), `SCHEMA_VERSION`, `emptyArtifact`, `serializeArtifact` (stable key ordering), `newId`. vscode-free. | +| `src/store.ts` | `CoauthorStore`: `sidecarPath`/`load`/`save` over Node `fs`; one sidecar per document under `.threads/`. vscode-free. | +| `src/anchorer.ts` | `OffsetRange`, `buildFingerprint`, `resolve` (ladder + orphan), `shift` (live offsets). vscode-free. | +| `src/threadModel.ts` | vscode-free Artifact mutations: `addThread`, `appendMessage`, `setStatus`. | +| `src/threadController.ts` | vscode layer: CommentController, create/reply/resolve commands, `renderAll`, live change tracking, FileSystemWatcher re-anchor, orphaned rendering. | +| `src/extension.ts` | `activate`/`deactivate`; wires ThreadController; keeps `showClineSdkInfo`; returns `{ threadController }` test API. | +| `test/model.test.ts` | vitest: serialize round-trip + stable formatting. | +| `test/store.test.ts` | vitest: save→load round-trip in a temp dir; byte-stable re-save. | +| `test/anchorer.test.ts` | vitest: fingerprint build; resolution ladder (exact/context/lineHint/orphan); shift cases. | +| `test/threadModel.test.ts` | vitest: addThread / appendMessage / setStatus. | +| `tsconfig.e2e.json` | Emit `test/e2e` + `src` to `out/` as CommonJS for the host test run. | +| `test/e2e/runTest.ts` | `@vscode/test-electron` launcher (downloads VS Code, opens a temp workspace). | +| `test/e2e/suite/index.ts` | mocha runner that globs compiled `*.test.js`. | +| `test/e2e/suite/threads.test.ts` | host E2E: create→reply→resolve→persist→reload→re-anchor→orphan. | +| `test/e2e/fixtures/workspace/docs/sample.md` | E2E fixture document. | +| `package.json` | Add F2 commands/menus contributions, `onStartupFinished` activation, E2E scripts + dev deps. | +| `vitest.config.ts` | Exclude `test/e2e/**` (those require `vscode`). | +| `.gitignore` | Ignore E2E-generated `.threads/` under the fixture. | +| `README.md` | Document the F2 feature + how to run unit and host E2E tests. | + +--- + +## Task 1 (SLICE-1): Artifact schema + CoauthorStore + +**Files:** +- Create: `src/model.ts` +- Test: `test/model.test.ts` +- Create: `src/store.ts` +- Test: `test/store.test.ts` +- Modify: `vitest.config.ts` (exclude E2E dir up front so later tasks don't break `npm test`) + +- [ ] **Step 1: Exclude the (future) E2E dir from vitest** + +`vitest.config.ts` (replace whole file): + +```ts +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + environment: "node", + include: ["test/**/*.test.ts"], + exclude: ["test/e2e/**", "node_modules/**"], + }, +}); +``` + +- [ ] **Step 2: Write the failing model test** + +`test/model.test.ts`: + +```ts +import { describe, it, expect } from "vitest"; +import { + SCHEMA_VERSION, + emptyArtifact, + serializeArtifact, + type Artifact, +} from "../src/model"; + +function sampleArtifact(): Artifact { + return { + schemaVersion: SCHEMA_VERSION, + document: { path: "docs/spec.md" }, + anchors: { + a2: { fingerprint: { text: "beta", before: "x", after: "y", lineHint: 5 } }, + a1: { fingerprint: { text: "alpha", before: "", after: "", lineHint: 1 } }, + }, + threads: [ + { + id: "t1", + anchorId: "a1", + status: "open", + messages: [ + { id: "m1", author: { kind: "human", id: "ben" }, body: "hi", createdAt: "2026-06-10T00:00:00.000Z" }, + ], + }, + ], + attributions: [], + proposals: [], + }; +} + +describe("serializeArtifact", () => { + it("round-trips through JSON.parse", () => { + const a = sampleArtifact(); + const parsed = JSON.parse(serializeArtifact(a)) as Artifact; + expect(parsed).toEqual(a); + }); + + it("emits stable, sorted anchor keys and a trailing newline regardless of insertion order", () => { + const a = sampleArtifact(); + const out = serializeArtifact(a); + expect(out.endsWith("\n")).toBe(true); + // a1 must serialize before a2 even though a2 was inserted first + expect(out.indexOf('"a1"')).toBeLessThan(out.indexOf('"a2"')); + // byte-identical on re-serialize + expect(serializeArtifact(JSON.parse(out))).toBe(out); + }); + + it("emptyArtifact has the shared F3/F4 extension points present and empty", () => { + const e = emptyArtifact("docs/x.md"); + expect(e.schemaVersion).toBe(SCHEMA_VERSION); + expect(e.document.path).toBe("docs/x.md"); + expect(e.anchors).toEqual({}); + expect(e.threads).toEqual([]); + expect(e.attributions).toEqual([]); + expect(e.proposals).toEqual([]); + }); +}); +``` + +- [ ] **Step 3: Run the test to verify it fails** + +Run: `npm test -- test/model.test.ts` +Expected: FAIL — cannot resolve `../src/model`. + +- [ ] **Step 4: Write `src/model.ts`** + +```ts +/** + * Versioned coauthoring artifact schema (spec §6.3) — the shared git-native + * envelope under every coauthoring capability. `anchors` and the `Provenance` + * author field are SHARED primitives (INV-4): F3 (attributions) and F4 + * (proposals) add arrays that reference the same shapes without a format break. + * + * vscode-free and pure, so it is unit-testable in Node (mirrors src/cline.ts). + */ +import { randomUUID } from "node:crypto"; + +export const SCHEMA_VERSION = 1 as const; + +/** Anchored text + bounded context + a line tie-breaker (spec §6.3). */ +export interface Fingerprint { + text: string; + /** <= ~3 lines / 120 chars leading context. */ + before: string; + /** <= ~3 lines / 120 chars trailing context. */ + after: string; + /** 0-based line of the anchor start; a tie-breaker, NOT truth (INV-3). */ + lineHint: number; +} + +export interface Anchor { + fingerprint: Fingerprint; +} + +/** The reusable author field (spec §6.3). `agent` only present when kind=agent. */ +export type Provenance = + | { kind: "human"; id: string } + | { kind: "agent"; id: string; agent: { sdk: string; model: string; sessionId: string } }; + +export interface Message { + id: string; + author: Provenance; + body: string; + /** ISO-8601. */ + createdAt: string; +} + +export type ThreadStatus = "open" | "resolved"; + +export interface Thread { + id: string; + anchorId: string; + status: ThreadStatus; + messages: Message[]; +} + +export interface Artifact { + schemaVersion: number; + /** repo-relative path; the sidecar key. */ + document: { path: string }; + /** SHARED primitive (INV-4). */ + anchors: Record; + threads: Thread[]; + /** F3 extension point — reuses anchors[]; not implemented in F2. */ + attributions: unknown[]; + /** F4 extension point — reuses anchors[] + provenance; not in F2. */ + proposals: unknown[]; +} + +export function emptyArtifact(docPath: string): Artifact { + return { + schemaVersion: SCHEMA_VERSION, + document: { path: docPath }, + anchors: {}, + threads: [], + attributions: [], + proposals: [], + }; +} + +/** Stable, generated id (spec §6.3). */ +export function newId(prefix: string): string { + return `${prefix}_${randomUUID()}`; +} + +function serializeProvenance(p: Provenance): Record { + return p.kind === "agent" + ? { kind: p.kind, id: p.id, agent: { sdk: p.agent.sdk, model: p.agent.model, sessionId: p.agent.sessionId } } + : { kind: p.kind, id: p.id }; +} + +/** + * Pretty-printed JSON with STABLE key ordering (anchors sorted by id), so + * sidecar diffs stay minimal and merge-friendly (spec §6.3, INV-2). Always + * ends with a trailing newline. + */ +export function serializeArtifact(a: Artifact): string { + const canonical = { + schemaVersion: a.schemaVersion, + document: { path: a.document.path }, + anchors: Object.fromEntries( + Object.keys(a.anchors) + .sort() + .map((k) => [ + k, + { + fingerprint: { + text: a.anchors[k].fingerprint.text, + before: a.anchors[k].fingerprint.before, + after: a.anchors[k].fingerprint.after, + lineHint: a.anchors[k].fingerprint.lineHint, + }, + }, + ]), + ), + threads: a.threads.map((t) => ({ + id: t.id, + anchorId: t.anchorId, + status: t.status, + messages: t.messages.map((m) => ({ + id: m.id, + author: serializeProvenance(m.author), + body: m.body, + createdAt: m.createdAt, + })), + })), + attributions: a.attributions, + proposals: a.proposals, + }; + return JSON.stringify(canonical, null, 2) + "\n"; +} +``` + +- [ ] **Step 5: Run the model test to verify it passes** + +Run: `npm test -- test/model.test.ts` +Expected: PASS — 3 tests green. + +- [ ] **Step 6: Write the failing store test** + +`test/store.test.ts`: + +```ts +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { CoauthorStore } from "../src/store"; +import { emptyArtifact, newId, serializeArtifact, type Artifact } from "../src/model"; + +let root: string; + +beforeEach(() => { + root = fs.mkdtempSync(path.join(os.tmpdir(), "coauthor-store-")); +}); +afterEach(() => { + fs.rmSync(root, { recursive: true, force: true }); +}); + +function withThread(): Artifact { + const a = emptyArtifact("docs/spec.md"); + a.anchors["a1"] = { fingerprint: { text: "hello", before: "", after: "", lineHint: 0 } }; + a.threads.push({ + id: "t1", + anchorId: "a1", + status: "open", + messages: [{ id: newId("m"), author: { kind: "human", id: "ben" }, body: "note", createdAt: "2026-06-10T00:00:00.000Z" }], + }); + return a; +} + +describe("CoauthorStore", () => { + it("returns null for a document with no sidecar", () => { + const store = new CoauthorStore(root); + expect(store.load("docs/spec.md")).toBeNull(); + }); + + it("computes the sidecar path as .threads/.json", () => { + const store = new CoauthorStore(root); + expect(store.sidecarPath("docs/spec.md")).toBe(path.join(root, ".threads", "docs", "spec.md.json")); + }); + + it("save then load round-trips the artifact", () => { + const store = new CoauthorStore(root); + const a = withThread(); + store.save("docs/spec.md", a); + expect(store.load("docs/spec.md")).toEqual(a); + }); + + it("writes byte-stable, pretty JSON (re-save is identical)", () => { + const store = new CoauthorStore(root); + const a = withThread(); + store.save("docs/spec.md", a); + const first = fs.readFileSync(store.sidecarPath("docs/spec.md"), "utf8"); + store.save("docs/spec.md", store.load("docs/spec.md")!); + const second = fs.readFileSync(store.sidecarPath("docs/spec.md"), "utf8"); + expect(second).toBe(first); + expect(first).toBe(serializeArtifact(a)); + }); +}); +``` + +- [ ] **Step 7: Run the store test to verify it fails** + +Run: `npm test -- test/store.test.ts` +Expected: FAIL — cannot resolve `../src/store`. + +- [ ] **Step 8: Write `src/store.ts`** + +```ts +/** + * CoauthorStore — load/save the per-document `.threads/` sidecar (spec §6.4). + * Git-native, serverless, plain pretty JSON (INV-2). vscode-free (Node fs only), + * so it is unit-testable; the FileSystemWatcher that triggers re-anchoring on + * external change is wired in the vscode layer (ThreadController, SLICE-4). + */ +import * as fs from "node:fs"; +import * as path from "node:path"; +import { serializeArtifact, type Artifact } from "./model"; + +export class CoauthorStore { + /** @param rootDir absolute workspace-folder root that owns the `.threads/` tree. */ + constructor(private readonly rootDir: string) {} + + /** `.threads/.json` (spec §6.3). */ + sidecarPath(docPath: string): string { + return path.join(this.rootDir, ".threads", `${docPath}.json`); + } + + load(docPath: string): Artifact | null { + const p = this.sidecarPath(docPath); + if (!fs.existsSync(p)) return null; + return JSON.parse(fs.readFileSync(p, "utf8")) as Artifact; + } + + save(docPath: string, artifact: Artifact): void { + const p = this.sidecarPath(docPath); + fs.mkdirSync(path.dirname(p), { recursive: true }); + fs.writeFileSync(p, serializeArtifact(artifact), "utf8"); + } +} +``` + +- [ ] **Step 9: Run the full unit suite to verify it passes** + +Run: `npm test` +Expected: PASS — model + store tests green (plus the existing `cline` test). + +- [ ] **Step 10: Typecheck** + +Run: `npm run typecheck` +Expected: PASS — no type errors. + +- [ ] **Step 11: Commit** + +```bash +git add src/model.ts test/model.test.ts src/store.ts test/store.test.ts vitest.config.ts +git commit -m "F2 SLICE-1: artifact schema + CoauthorStore with round-trip tests (#4)" +``` + +--- + +## Task 2 (SLICE-2): Anchorer — fingerprint, resolution ladder, live shift + +**Files:** +- Create: `src/anchorer.ts` +- Test: `test/anchorer.test.ts` + +- [ ] **Step 1: Write the failing anchorer test** + +`test/anchorer.test.ts`: + +```ts +import { describe, it, expect } from "vitest"; +import { buildFingerprint, resolve, shift, type OffsetRange } from "../src/anchorer"; + +const DOC = "line0\nline1 needle here\nline2\nneedle again on line3\n"; + +describe("buildFingerprint", () => { + it("captures the exact text, bounded context, and 0-based lineHint", () => { + const start = DOC.indexOf("needle here"); + const range: OffsetRange = { start, end: start + "needle".length }; + const fp = buildFingerprint(DOC, range); + expect(fp.text).toBe("needle"); + expect(fp.lineHint).toBe(1); + expect(fp.before.endsWith("line1 ")).toBe(true); + expect(fp.after.startsWith(" here")).toBe(true); + expect(fp.before.length).toBeLessThanOrEqual(120); + expect(fp.after.length).toBeLessThanOrEqual(120); + }); +}); + +describe("resolve", () => { + it("exact-unique: returns the single occurrence's range", () => { + const fp = { text: "line2", before: "", after: "", lineHint: 0 }; + const start = DOC.indexOf("line2"); + expect(resolve(DOC, fp)).toEqual({ start, end: start + 5 }); + }); + + it("context-disambiguated: picks the occurrence whose before/after match", () => { + const fp = { text: "needle", before: "line1 ", after: " here", lineHint: 99 }; + const start = DOC.indexOf("needle here"); + expect(resolve(DOC, fp)).toEqual({ start, end: start + 6 }); + }); + + it("lineHint tiebreak: when context does not disambiguate, the closest line wins", () => { + // both occurrences of 'needle', no usable context, hint points at line 3 + const fp = { text: "needle", before: "", after: "", lineHint: 3 }; + const start = DOC.indexOf("needle again"); + expect(resolve(DOC, fp)).toEqual({ start, end: start + 6 }); + }); + + it("orphaned: returns 'orphaned' when the text is gone", () => { + const fp = { text: "absent-text", before: "", after: "", lineHint: 0 }; + expect(resolve(DOC, fp)).toBe("orphaned"); + }); + + it("orphaned: returns 'orphaned' rather than guessing on an unbreakable tie (INV-1)", () => { + const doc = "needle\n....\nneedle\n"; // two identical occurrences, equidistant from hint + const fp = { text: "needle", before: "", after: "", lineHint: 1 }; + expect(resolve(doc, fp)).toBe("orphaned"); + }); +}); + +describe("shift", () => { + it("edit entirely before the range shifts both endpoints by the delta", () => { + const r: OffsetRange = { start: 10, end: 15 }; + // insert 2 chars at offset 0 (replace [0,0) with len 2) + expect(shift(r, { start: 0, end: 0, newLength: 2 })).toEqual({ start: 12, end: 17 }); + }); + + it("edit entirely after the range leaves it unchanged", () => { + const r: OffsetRange = { start: 2, end: 5 }; + expect(shift(r, { start: 10, end: 12, newLength: 0 })).toEqual({ start: 2, end: 5 }); + }); + + it("edit overlapping the range clamps the touched endpoints to the edit start", () => { + const r: OffsetRange = { start: 5, end: 10 }; + // replace [3,7) with 1 char (delta = -3) + expect(shift(r, { start: 3, end: 7, newLength: 1 })).toEqual({ start: 3, end: 7 }); + }); +}); +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `npm test -- test/anchorer.test.ts` +Expected: FAIL — cannot resolve `../src/anchorer`. + +- [ ] **Step 3: Write `src/anchorer.ts`** + +```ts +/** + * Anchorer — hybrid anchoring (spec §6.5). The durable FINGERPRINT is the source + * of truth for an anchor's location (INV-3); live OFFSET ranges are a within- + * session optimization. The resolution ladder is: + * exact-unique → context-disambiguated → lineHint tiebreak → orphaned. + * It NEVER guesses on an unbreakable tie — it orphans instead (INV-1). + * + * vscode-free: operates on plain strings and character-offset ranges, so it is + * unit-testable in Node. The vscode layer converts OffsetRange <-> vscode.Range. + */ +import type { Fingerprint } from "./model"; + +export interface OffsetRange { + start: number; + end: number; +} + +/** A document edit: the half-open range [start,end) was replaced with text of `newLength`. */ +export interface TextEdit { + start: number; + end: number; + newLength: number; +} + +const MAX_CTX_CHARS = 120; +const MAX_CTX_LINES = 3; + +function lineNumberAt(doc: string, offset: number): number { + let line = 0; + const bound = Math.min(offset, doc.length); + for (let i = 0; i < bound; i++) if (doc.charCodeAt(i) === 10) line++; + return line; +} + +function leadingContext(doc: string, start: number): string { + let s = doc.slice(Math.max(0, start - MAX_CTX_CHARS), start); + const lines = s.split("\n"); + if (lines.length > MAX_CTX_LINES) s = lines.slice(lines.length - MAX_CTX_LINES).join("\n"); + return s; +} + +function trailingContext(doc: string, end: number): string { + let s = doc.slice(end, Math.min(doc.length, end + MAX_CTX_CHARS)); + const lines = s.split("\n"); + if (lines.length > MAX_CTX_LINES) s = lines.slice(0, MAX_CTX_LINES).join("\n"); + return s; +} + +export function buildFingerprint(docText: string, range: OffsetRange): Fingerprint { + return { + text: docText.slice(range.start, range.end), + before: leadingContext(docText, range.start), + after: trailingContext(docText, range.end), + lineHint: lineNumberAt(docText, range.start), + }; +} + +function allIndexesOf(hay: string, needle: string): number[] { + if (needle.length === 0) return []; + const out: number[] = []; + let from = 0; + let idx = hay.indexOf(needle, from); + while (idx !== -1) { + out.push(idx); + from = idx + 1; + idx = hay.indexOf(needle, from); + } + return out; +} + +function contextMatches(doc: string, i: number, fp: Fingerprint): boolean { + const beforeOk = fp.before.length === 0 || doc.slice(Math.max(0, i - fp.before.length), i) === fp.before; + const afterStart = i + fp.text.length; + const afterOk = fp.after.length === 0 || doc.slice(afterStart, afterStart + fp.after.length) === fp.after; + return beforeOk && afterOk; +} + +const rangeAt = (i: number, text: string): OffsetRange => ({ start: i, end: i + text.length }); + +/** + * Re-resolve a fingerprint against current text. Returns the resolved range or + * "orphaned" when no confident, unique match exists (INV-1). + */ +export function resolve(docText: string, fp: Fingerprint): OffsetRange | "orphaned" { + const occ = allIndexesOf(docText, fp.text); + if (occ.length === 0) return "orphaned"; + if (occ.length === 1) return rangeAt(occ[0], fp.text); + + // Multiple exact matches: try context disambiguation. + const ctx = occ.filter((i) => contextMatches(docText, i, fp)); + if (ctx.length === 1) return rangeAt(ctx[0], fp.text); + + // Still ambiguous: break ties by proximity to lineHint within the best pool. + const pool = ctx.length > 0 ? ctx : occ; + const dists = pool.map((i) => ({ i, d: Math.abs(lineNumberAt(docText, i) - fp.lineHint) })); + const min = Math.min(...dists.map((x) => x.d)); + const closest = dists.filter((x) => x.d === min); + if (closest.length === 1) return rangeAt(closest[0].i, fp.text); + + // Unbreakable tie — refuse to guess. + return "orphaned"; +} + +/** Maintain a live range across an in-session edit (spec §6.4 `shift`). */ +export function shift(range: OffsetRange, edit: TextEdit): OffsetRange { + const delta = edit.newLength - (edit.end - edit.start); + const point = (p: number): number => { + if (p <= edit.start) return p; + if (p >= edit.end) return p + delta; + return edit.start; // inside the replaced span — clamp to its start + }; + return { start: point(range.start), end: point(range.end) }; +} +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `npm test -- test/anchorer.test.ts` +Expected: PASS — fingerprint, all four resolution-ladder cases, and the three shift cases green. + +- [ ] **Step 5: Typecheck** + +Run: `npm run typecheck` +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add src/anchorer.ts test/anchorer.test.ts +git commit -m "F2 SLICE-2: Anchorer (fingerprint, resolution ladder, live shift) (#4)" +``` + +--- + +## Task 3 (SLICE-3): ThreadController wiring to the Comments API + +This task has two halves: (a) a **vscode-free** `threadModel.ts` carrying the Artifact mutations (unit-tested), and (b) the `threadController.ts` vscode wiring + the `package.json` contributions (typecheck/build-verified; behavior is exercised by SLICE-5 E2E, since the native Comments UI cannot be driven from vitest). + +**Files:** +- Create: `src/threadModel.ts` +- Test: `test/threadModel.test.ts` +- Create: `src/threadController.ts` +- Modify: `src/extension.ts` +- Modify: `package.json` (contributions + activation) + +### 3a — vscode-free thread mutations (TDD) + +- [ ] **Step 1: Write the failing threadModel test** + +`test/threadModel.test.ts`: + +```ts +import { describe, it, expect } from "vitest"; +import { emptyArtifact, type Provenance } from "../src/model"; +import { addThread, appendMessage, setStatus } from "../src/threadModel"; + +const BEN: Provenance = { kind: "human", id: "ben" }; +const fp = { text: "anchored", before: "a", after: "b", lineHint: 3 }; + +describe("addThread", () => { + it("adds an anchor + an open thread with the first message and returns their ids", () => { + const a = emptyArtifact("docs/x.md"); + const { threadId, anchorId } = addThread(a, fp, { author: BEN, body: "first" }); + expect(Object.keys(a.anchors)).toContain(anchorId); + expect(a.anchors[anchorId].fingerprint).toEqual(fp); + const t = a.threads.find((x) => x.id === threadId)!; + expect(t.anchorId).toBe(anchorId); + expect(t.status).toBe("open"); + expect(t.messages).toHaveLength(1); + expect(t.messages[0].body).toBe("first"); + expect(t.messages[0].author).toEqual(BEN); + expect(t.messages[0].id).toMatch(/^m_/); + expect(t.messages[0].createdAt).toMatch(/^\d{4}-\d{2}-\d{2}T/); + }); +}); + +describe("appendMessage", () => { + it("appends a message to the named thread", () => { + const a = emptyArtifact("docs/x.md"); + const { threadId } = addThread(a, fp, { author: BEN, body: "first" }); + appendMessage(a, threadId, { author: BEN, body: "reply" }); + const t = a.threads.find((x) => x.id === threadId)!; + expect(t.messages.map((m) => m.body)).toEqual(["first", "reply"]); + }); + + it("throws for an unknown thread id", () => { + const a = emptyArtifact("docs/x.md"); + expect(() => appendMessage(a, "nope", { author: BEN, body: "x" })).toThrow(/nope/); + }); +}); + +describe("setStatus", () => { + it("flips a thread between open and resolved", () => { + const a = emptyArtifact("docs/x.md"); + const { threadId } = addThread(a, fp, { author: BEN, body: "first" }); + setStatus(a, threadId, "resolved"); + expect(a.threads[0].status).toBe("resolved"); + setStatus(a, threadId, "open"); + expect(a.threads[0].status).toBe("open"); + }); +}); +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `npm test -- test/threadModel.test.ts` +Expected: FAIL — cannot resolve `../src/threadModel`. + +- [ ] **Step 3: Write `src/threadModel.ts`** + +```ts +/** + * vscode-free mutations on the Artifact (spec §6.5). Keeping thread logic here + * (not in the vscode controller) keeps it unit-testable; ThreadController is a + * thin adapter that calls these and persists via CoauthorStore. + */ +import { + newId, + type Artifact, + type Fingerprint, + type Provenance, + type ThreadStatus, +} from "./model"; + +export interface NewMessage { + author: Provenance; + body: string; +} + +function message(m: NewMessage) { + return { id: newId("m"), author: m.author, body: m.body, createdAt: new Date().toISOString() }; +} + +/** Lazily-created anchor + thread for a new region discussion (PUC-1). */ +export function addThread( + artifact: Artifact, + fingerprint: Fingerprint, + first: NewMessage, +): { threadId: string; anchorId: string } { + const anchorId = newId("a"); + const threadId = newId("t"); + artifact.anchors[anchorId] = { fingerprint }; + artifact.threads.push({ id: threadId, anchorId, status: "open", messages: [message(first)] }); + return { threadId, anchorId }; +} + +/** Append a reply (PUC-2). Throws if the thread is unknown. */ +export function appendMessage(artifact: Artifact, threadId: string, m: NewMessage): void { + const t = artifact.threads.find((x) => x.id === threadId); + if (!t) throw new Error(`unknown thread id: ${threadId}`); + t.messages.push(message(m)); +} + +/** Resolve/reopen a thread (PUC-2). Throws if the thread is unknown. */ +export function setStatus(artifact: Artifact, threadId: string, status: ThreadStatus): void { + const t = artifact.threads.find((x) => x.id === threadId); + if (!t) throw new Error(`unknown thread id: ${threadId}`); + t.status = status; +} +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `npm test -- test/threadModel.test.ts` +Expected: PASS — addThread / appendMessage / setStatus green. + +### 3b — vscode ThreadController + contributions + +- [ ] **Step 5: Add F2 contributions + activation to `package.json`** + +Replace the `activationEvents` and `contributes` blocks (keep everything else, including `cowriting.showClineSdkInfo`): + +```json + "activationEvents": ["onStartupFinished"], + "contributes": { + "commands": [ + { + "command": "cowriting.showClineSdkInfo", + "title": "Cowriting: Show Cline SDK Info", + "category": "Cowriting" + }, + { + "command": "cowriting.createThread", + "title": "Cowriting: Add Coauthoring Thread on Selection", + "category": "Cowriting" + }, + { "command": "cowriting.reply", "title": "Reply", "category": "Cowriting" }, + { "command": "cowriting.resolveThread", "title": "Resolve Thread", "category": "Cowriting" }, + { "command": "cowriting.reopenThread", "title": "Reopen Thread", "category": "Cowriting" } + ], + "menus": { + "comments/commentThread/context": [ + { "command": "cowriting.reply", "group": "inline", "when": "commentController == cowriting.threads" } + ], + "comments/commentThread/title": [ + { + "command": "cowriting.resolveThread", + "group": "inline", + "when": "commentController == cowriting.threads && commentThread =~ /^open$/" + }, + { + "command": "cowriting.reopenThread", + "group": "inline", + "when": "commentController == cowriting.threads && commentThread =~ /^resolved$/" + } + ] + } + }, +``` + +Note: we set each `vscode.CommentThread.contextValue` to its status string (`"open"`/`"resolved"`), so the `commentThread =~ /…/` clauses show Resolve only on open threads and Reopen only on resolved ones. + +- [ ] **Step 6: Write `src/threadController.ts`** + +```ts +/** + * ThreadController — the thin editor-facing layer (spec §6.4). Wires + * CoauthorStore + Anchorer + threadModel to vscode.comments: create-on- + * selection, reply, resolve, render, live-range tracking, reload, and + * FileSystemWatcher-driven external re-anchoring (SLICE-4). Threads are NEVER + * silently moved — an unresolvable anchor renders as an orphaned thread (INV-1). + */ +import * as vscode from "vscode"; +import { CoauthorStore } from "./store"; +import { emptyArtifact, type Artifact, type Provenance } from "./model"; +import { buildFingerprint, resolve, shift, type OffsetRange } from "./anchorer"; +import { addThread, appendMessage, setStatus } from "./threadModel"; + +/** Test-facing snapshot of what is currently rendered for a document. */ +export interface RenderedThread { + id: string; + status: "open" | "resolved"; + orphaned: boolean; + range: { startLine: number; endLine: number }; +} + +interface DocState { + docPath: string; + uri: vscode.Uri; + artifact: Artifact; + /** thread id -> vscode thread. */ + vsThreads: Map; + /** thread id -> live offset range (within-session optimization, INV-3). */ + live: Map; + /** thread id -> orphaned flag for the current render. */ + orphaned: Map; +} + +export class ThreadController implements vscode.Disposable { + private readonly controller: vscode.CommentController; + private readonly disposables: vscode.Disposable[] = []; + private readonly docs = new Map(); // keyed by docPath + /** sidecar paths we just wrote, to ignore our own watcher events. */ + private readonly selfWrites = new Set(); + + constructor(private readonly store: CoauthorStore, private readonly rootDir: string) { + this.controller = vscode.comments.createCommentController("cowriting.threads", "Coauthoring Threads"); + this.controller.commentingRangeProvider = { + provideCommentingRanges: (document) => { + if (!this.isInRoot(document.uri)) return []; + return [new vscode.Range(0, 0, Math.max(0, document.lineCount - 1), 0)]; + }, + }; + this.disposables.push(this.controller); + + this.disposables.push( + vscode.commands.registerCommand("cowriting.createThread", () => this.createThreadOnSelection()), + vscode.commands.registerCommand("cowriting.reply", (r: vscode.CommentReply) => this.reply(r)), + vscode.commands.registerCommand("cowriting.resolveThread", (t: vscode.CommentThread) => + this.setThreadStatus(t, "resolved"), + ), + vscode.commands.registerCommand("cowriting.reopenThread", (t: vscode.CommentThread) => + this.setThreadStatus(t, "open"), + ), + vscode.workspace.onDidChangeTextDocument((e) => this.onDidChange(e)), + vscode.workspace.onDidSaveTextDocument((d) => this.onDidSave(d)), + ); + + // Re-anchor on external change to any sidecar (a git pull, a manual edit). + const watcher = vscode.workspace.createFileSystemWatcher("**/.threads/**/*.json"); + const onSidecar = (uri: vscode.Uri) => this.onExternalSidecarChange(uri); + watcher.onDidChange(onSidecar); + watcher.onDidCreate(onSidecar); + this.disposables.push(watcher); + } + + private isInRoot(uri: vscode.Uri): boolean { + return uri.scheme === "file" && uri.fsPath.startsWith(this.rootDir); + } + + private docPathOf(uri: vscode.Uri): string { + return vscode.workspace.asRelativePath(uri, false); + } + + private currentAuthor(): Provenance { + const id = (vscode.workspace.getConfiguration("git").get("user.name")) || process.env.USER || "human"; + return { kind: "human", id }; + } + + private persist(state: DocState): void { + this.selfWrites.add(this.store.sidecarPath(state.docPath)); + this.store.save(state.docPath, state.artifact); + } + + private ensureState(document: vscode.TextDocument): DocState { + const docPath = this.docPathOf(document.uri); + let state = this.docs.get(docPath); + if (!state) { + state = { + docPath, + uri: document.uri, + artifact: this.store.load(docPath) ?? emptyArtifact(docPath), + vsThreads: new Map(), + live: new Map(), + orphaned: new Map(), + }; + this.docs.set(docPath, state); + } + return state; + } + + // ---- PUC-1: create on selection ------------------------------------------------- + + async createThreadOnSelection(firstBody = "New thread"): Promise { + const editor = vscode.window.activeTextEditor; + if (!editor || editor.selection.isEmpty || !this.isInRoot(editor.document.uri)) return undefined; + const document = editor.document; + const state = this.ensureState(document); + const offsets: OffsetRange = { + start: document.offsetAt(editor.selection.start), + end: document.offsetAt(editor.selection.end), + }; + const fp = buildFingerprint(document.getText(), offsets); + const { threadId } = addThread(state.artifact, fp, { author: this.currentAuthor(), body: firstBody }); + this.persist(state); + this.renderThread(document, state, threadId, offsets, false); + return threadId; + } + + // ---- PUC-2: reply / resolve ----------------------------------------------------- + + reply(r: vscode.CommentReply): void { + const threadId = this.threadIdOf(r.thread); + const state = this.stateOfThread(r.thread); + if (!threadId || !state) return; + appendMessage(state.artifact, threadId, { author: this.currentAuthor(), body: r.text }); + this.persist(state); + this.refreshComments(r.thread, state, threadId); + } + + private setThreadStatus(vsThread: vscode.CommentThread, status: "open" | "resolved"): void { + const threadId = this.threadIdOf(vsThread); + const state = this.stateOfThread(vsThread); + if (!threadId || !state) return; + setStatus(state.artifact, threadId, status); + this.persist(state); + vsThread.state = status === "resolved" ? vscode.CommentThreadState.Resolved : vscode.CommentThreadState.Unresolved; + vsThread.contextValue = status; + } + + // ---- PUC-3/4: render, reload, re-anchor ----------------------------------------- + + /** Load + (re)render every thread for a document at its resolved anchor (or orphaned). */ + renderAll(document: vscode.TextDocument): void { + const docPath = this.docPathOf(document.uri); + const state = this.ensureState(document); + // fresh artifact from disk (reload / external change) + state.artifact = this.store.load(docPath) ?? emptyArtifact(docPath); + for (const vsThread of state.vsThreads.values()) vsThread.dispose(); + state.vsThreads.clear(); + state.live.clear(); + state.orphaned.clear(); + const text = document.getText(); + for (const thread of state.artifact.threads) { + const fp = state.artifact.anchors[thread.anchorId]?.fingerprint; + const resolved = fp ? resolve(text, fp) : "orphaned"; + if (resolved === "orphaned") { + const line = fp ? Math.min(fp.lineHint, Math.max(0, document.lineCount - 1)) : 0; + this.renderThread(document, state, thread.id, { start: document.offsetAt(new vscode.Position(line, 0)), end: document.offsetAt(new vscode.Position(line, 0)) }, true); + } else { + this.renderThread(document, state, thread.id, resolved, false); + } + } + } + + private onExternalSidecarChange(uri: vscode.Uri): void { + const p = uri.fsPath; + if (this.selfWrites.has(p)) { + this.selfWrites.delete(p); + return; // our own write + } + for (const state of this.docs.values()) { + if (this.store.sidecarPath(state.docPath) === p) { + const doc = vscode.workspace.textDocuments.find((d) => this.docPathOf(d.uri) === state.docPath); + if (doc) this.renderAll(doc); + } + } + } + + private onDidChange(e: vscode.TextDocumentChangeEvent): void { + const state = this.docs.get(this.docPathOf(e.document.uri)); + if (!state || state.live.size === 0) return; + for (const change of e.contentChanges) { + const edit = { start: change.rangeOffset, end: change.rangeOffset + change.rangeLength, newLength: change.text.length }; + for (const [id, range] of state.live) { + const next = shift(range, edit); + state.live.set(id, next); + const vsThread = state.vsThreads.get(id); + if (vsThread && !state.orphaned.get(id)) { + vsThread.range = new vscode.Range(e.document.positionAt(next.start), e.document.positionAt(next.end)); + } + } + } + } + + private onDidSave(document: vscode.TextDocument): void { + const state = this.docs.get(this.docPathOf(document.uri)); + if (!state || state.live.size === 0) return; + const text = document.getText(); + let changed = false; + for (const thread of state.artifact.threads) { + if (state.orphaned.get(thread.id)) continue; + const range = state.live.get(thread.id); + if (!range) continue; + state.artifact.anchors[thread.anchorId] = { fingerprint: buildFingerprint(text, range) }; + changed = true; + } + if (changed) this.persist(state); + } + + // ---- rendering helpers ---------------------------------------------------------- + + private renderThread( + document: vscode.TextDocument, + state: DocState, + threadId: string, + offsets: OffsetRange, + orphaned: boolean, + ): void { + const thread = state.artifact.threads.find((t) => t.id === threadId)!; + const range = new vscode.Range(document.positionAt(offsets.start), document.positionAt(offsets.end)); + const vsThread = this.controller.createCommentThread(document.uri, range, thread.messages.map((m) => this.toComment(m.body, m.author.id))); + vsThread.label = orphaned ? "⚠ Orphaned thread (anchor not found)" : undefined; + vsThread.contextValue = orphaned ? "orphaned" : thread.status; + vsThread.state = thread.status === "resolved" ? vscode.CommentThreadState.Resolved : vscode.CommentThreadState.Unresolved; + vsThread.collapsibleState = vscode.CommentThreadCollapsibleState.Collapsed; + state.vsThreads.set(threadId, vsThread); + state.live.set(threadId, offsets); + state.orphaned.set(threadId, orphaned); + } + + private refreshComments(vsThread: vscode.CommentThread, state: DocState, threadId: string): void { + const thread = state.artifact.threads.find((t) => t.id === threadId)!; + vsThread.comments = thread.messages.map((m) => this.toComment(m.body, m.author.id)); + } + + private toComment(body: string, authorName: string): vscode.Comment { + return { + body: new vscode.MarkdownString(body), + mode: vscode.CommentMode.Preview, + author: { name: authorName }, + }; + } + + private threadIdOf(vsThread: vscode.CommentThread): string | undefined { + for (const state of this.docs.values()) { + for (const [id, t] of state.vsThreads) if (t === vsThread) return id; + } + return undefined; + } + + private stateOfThread(vsThread: vscode.CommentThread): DocState | undefined { + for (const state of this.docs.values()) { + for (const t of state.vsThreads.values()) if (t === vsThread) return state; + } + return undefined; + } + + // ---- test-facing surface -------------------------------------------------------- + + getRendered(docPath: string): RenderedThread[] { + const state = this.docs.get(docPath); + if (!state) return []; + const out: RenderedThread[] = []; + for (const [id, vsThread] of state.vsThreads) { + const t = state.artifact.threads.find((x) => x.id === id)!; + out.push({ + id, + status: t.status, + orphaned: !!state.orphaned.get(id), + range: { startLine: vsThread.range.start.line, endLine: vsThread.range.end.line }, + }); + } + return out; + } + + dispose(): void { + for (const d of this.disposables) d.dispose(); + } +} +``` + +- [ ] **Step 7: Wire the controller into `src/extension.ts`** + +Replace `src/extension.ts` with (keeps `showClineSdkInfo`, adds the controller, returns a test API): + +```ts +import * as vscode from "vscode"; +import { fetchSdkSummary } from "./cline"; +import { CoauthorStore } from "./store"; +import { ThreadController } from "./threadController"; + +const CHANNEL_NAME = "Cowriting (Cline SDK)"; + +export interface CowritingApi { + threadController: ThreadController; +} + +export function activate(context: vscode.ExtensionContext): CowritingApi | undefined { + // --- POC command (Feature #2), unchanged --- + const output = vscode.window.createOutputChannel(CHANNEL_NAME); + context.subscriptions.push(output); + context.subscriptions.push( + vscode.commands.registerCommand("cowriting.showClineSdkInfo", async () => { + try { + const summary = await fetchSdkSummary(); + output.clear(); + output.appendLine(`@cline/sdk version: ${summary.version}`); + output.appendLine(`Builtin tools (${summary.tools.length}):`); + for (const tool of summary.tools) output.appendLine(` • ${tool.id} — ${tool.description}`); + output.show(true); + await vscode.window.showInformationMessage( + `Cline SDK ${summary.version} loaded — ${summary.tools.length} builtin tools. See the "${CHANNEL_NAME}" output channel.`, + ); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + output.appendLine(`Failed to drive @cline/sdk: ${message}`); + output.show(true); + await vscode.window.showErrorMessage(`Cowriting: failed to load @cline/sdk — ${message}`); + } + }), + ); + + // --- F2: region-anchored threads (Feature #4) --- + const root = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath; + if (!root) return undefined; // no workspace → nothing to anchor against + const store = new CoauthorStore(root); + const threadController = new ThreadController(store, root); + context.subscriptions.push(threadController); + + // Render threads for already-open editors, and on future opens. + const renderIfOpen = (doc: vscode.TextDocument) => { + if (doc.uri.scheme === "file" && doc.uri.fsPath.startsWith(root)) threadController.renderAll(doc); + }; + vscode.workspace.textDocuments.forEach(renderIfOpen); + context.subscriptions.push(vscode.workspace.onDidOpenTextDocument(renderIfOpen)); + + return { threadController }; +} + +export function deactivate(): void { + // Disposables registered on the context handle cleanup. +} +``` + +- [ ] **Step 8: Typecheck and build** + +Run: `npm run typecheck && npm run build` +Expected: PASS — no type errors; `out/extension.cjs` rebuilt. + +- [ ] **Step 9: Run the full unit suite** + +Run: `npm test` +Expected: PASS — model, store, anchorer, threadModel, and the existing cline test all green (E2E excluded). + +- [ ] **Step 10: Commit** + +```bash +git add src/threadModel.ts test/threadModel.test.ts src/threadController.ts src/extension.ts package.json +git commit -m "F2 SLICE-3: ThreadController on the Comments API + thread mutations (#4)" +``` + +--- + +## Task 4 (SLICE-4): Reload + external-change re-anchoring + orphaned surface + +The mechanics (FileSystemWatcher, `renderAll` reload, `onDidChange`/`onDidSave` live tracking, orphaned rendering) were implemented in Task 3's controller. This task **hardens and verifies** them with focused vscode-free unit tests of the re-anchor decision logic and a manual checklist; full behavioral proof lands in the SLICE-5 E2E. + +**Files:** +- Test: `test/reanchor.test.ts` (vscode-free coverage of the reload/edit/orphan decisions) + +- [ ] **Step 1: Write `test/reanchor.test.ts`** + +This locks the spec's resolution-ladder behavior under the three SLICE-4 scenarios (reload unchanged, edit-then-reanchor, edit-to-orphan) at the pure level the controller relies on: + +```ts +import { describe, it, expect } from "vitest"; +import { buildFingerprint, resolve } from "../src/anchorer"; + +const ORIGINAL = "intro\nThe quick brown fox\noutro\n"; + +describe("re-anchor scenarios (SLICE-4 decision logic)", () => { + it("reload, document unchanged: resolves to the same range", () => { + const start = ORIGINAL.indexOf("quick brown"); + const fp = buildFingerprint(ORIGINAL, { start, end: start + "quick brown".length }); + expect(resolve(ORIGINAL, fp)).toEqual({ start, end: start + "quick brown".length }); + }); + + it("external edit that moves the anchored text down: re-resolves to the new range", () => { + const start = ORIGINAL.indexOf("quick brown"); + const fp = buildFingerprint(ORIGINAL, { start, end: start + "quick brown".length }); + const edited = "a new first line\nanother line\n" + ORIGINAL; + const newStart = edited.indexOf("quick brown"); + expect(newStart).not.toBe(start); + expect(resolve(edited, fp)).toEqual({ start: newStart, end: newStart + "quick brown".length }); + }); + + it("external edit that deletes the anchored text: orphaned, never moved (INV-1)", () => { + const start = ORIGINAL.indexOf("quick brown"); + const fp = buildFingerprint(ORIGINAL, { start, end: start + "quick brown".length }); + const edited = "intro\nThe lazy dog\noutro\n"; + expect(resolve(edited, fp)).toBe("orphaned"); + }); +}); +``` + +- [ ] **Step 2: Run it to verify it passes** + +Run: `npm test -- test/reanchor.test.ts` +Expected: PASS — all three SLICE-4 decisions green (the controller composes exactly these calls). + +- [ ] **Step 3: Self-write-guard review (no code change expected)** + +Confirm in `src/threadController.ts` that `persist()` adds the sidecar path to `selfWrites` before `store.save`, and `onExternalSidecarChange()` consumes-and-skips that path — so the watcher does not re-render on our own writes (which would dispose the thread the user is mid-reply on). If missing, add it as shown in Task 3 Step 6. Run `npm run typecheck` after any change. + +- [ ] **Step 4: Commit** + +```bash +git add test/reanchor.test.ts +git commit -m "F2 SLICE-4: lock reload/re-anchor/orphan decision logic with unit tests (#4)" +``` + +--- + +## Task 5 (SLICE-5): Host E2E with `@vscode/test-electron` + +The Comments surface is native VS Code UI, so it is exercised in the real Extension Host. Where the harness cannot click the native Comments gutter, we drive the extension's returned `ThreadController` API and assert its rendered state + the on-disk sidecar (spec §6.8 fallback). + +**Files:** +- Modify: `package.json` (E2E dev deps + scripts) +- Create: `tsconfig.e2e.json` +- Create: `test/e2e/runTest.ts` +- Create: `test/e2e/suite/index.ts` +- Create: `test/e2e/suite/threads.test.ts` +- Create: `test/e2e/fixtures/workspace/docs/sample.md` +- Modify: `.gitignore` + +- [ ] **Step 1: Add E2E dev deps and scripts to `package.json`** + +Add to `devDependencies`: + +```json + "@vscode/test-electron": "^2.4.0", + "mocha": "^10.7.0", + "@types/mocha": "^10.0.7", + "glob": "^11.0.0" +``` + +Add to `scripts`: + +```json + "pretest:e2e": "npm run build && tsc -p tsconfig.e2e.json", + "test:e2e": "node ./out/test/e2e/runTest.js" +``` + +- [ ] **Step 2: Install the new dev deps** + +Run: `npm install` +Expected: lockfile updated; `@vscode/test-electron`, `mocha`, `glob` present; no error exit. + +- [ ] **Step 3: Create `tsconfig.e2e.json`** + +Compiles `src` + `test/e2e` to CommonJS under `out/` so the host can `require` them: + +```json +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "module": "CommonJS", + "moduleResolution": "Node", + "noEmit": false, + "outDir": "out", + "rootDir": ".", + "sourceMap": true, + "types": ["node", "mocha", "vscode"] + }, + "include": ["src", "test/e2e"] +} +``` + +Note: `src` is compiled here only so the E2E suite can import controller types/helpers if needed; the extension itself still loads from the esbuild bundle `out/extension.cjs` via `package.json#main`. The dynamic `import("@cline/sdk")` in `src/cline.ts` is untouched by F2 and is not invoked by these tests (INV-5). + +- [ ] **Step 4: Create the fixture document `test/e2e/fixtures/workspace/docs/sample.md`** + +```markdown +# Sample Document + +The quick brown fox jumps over the lazy dog. + +A second paragraph with the phrase anchor target inside it. + +Closing line. +``` + +- [ ] **Step 5: Create `test/e2e/runTest.ts`** + +Copies the fixture workspace to a temp dir (so the test's `.threads/` writes never dirty the repo), then launches the host pointed at it: + +```ts +import * as path from "path"; +import * as os from "os"; +import * as fs from "fs"; +import { runTests } from "@vscode/test-electron"; + +async function main(): Promise { + const projectRoot = path.resolve(__dirname, "../../../"); + const extensionDevelopmentPath = projectRoot; + const extensionTestsPath = path.resolve(__dirname, "./suite/index"); + + // Copy the fixture workspace to a temp dir so test writes are throwaway. + const fixture = path.resolve(projectRoot, "test/e2e/fixtures/workspace"); + const workspace = fs.mkdtempSync(path.join(os.tmpdir(), "cowriting-e2e-")); + fs.cpSync(fixture, workspace, { recursive: true }); + + try { + await runTests({ + extensionDevelopmentPath, + extensionTestsPath, + launchArgs: [workspace, "--disable-extensions"], + extensionTestsEnv: { E2E_WORKSPACE: workspace }, + }); + } finally { + fs.rmSync(workspace, { recursive: true, force: true }); + } +} + +main().catch((err) => { + console.error("E2E run failed:", err); + process.exit(1); +}); +``` + +- [ ] **Step 6: Create the mocha runner `test/e2e/suite/index.ts`** + +```ts +import * as path from "path"; +import Mocha from "mocha"; +import { glob } from "glob"; + +export async function run(): Promise { + const mocha = new Mocha({ ui: "bdd", color: true, timeout: 60000 }); + const testsRoot = path.resolve(__dirname); + const files = await glob("**/*.test.js", { cwd: testsRoot }); + for (const f of files) mocha.addFile(path.resolve(testsRoot, f)); + await new Promise((resolve, reject) => { + mocha.run((failures) => (failures > 0 ? reject(new Error(`${failures} E2E test(s) failed`)) : resolve())); + }); +} +``` + +- [ ] **Step 7: Create the host E2E `test/e2e/suite/threads.test.ts`** + +Exercises create → reply → resolve → persist → reload → re-anchor → orphan via the extension API + on-disk sidecar: + +```ts +import * as assert from "assert"; +import * as fs from "fs"; +import * as path from "path"; +import * as vscode from "vscode"; +import type { CowritingApi } from "../../../src/extension"; +import type { Artifact } from "../../../src/model"; + +const WS = process.env.E2E_WORKSPACE!; +const DOC_REL = "docs/sample.md"; + +function sidecarPath(): string { + return path.join(WS, ".threads", "docs", "sample.md.json"); +} +function readSidecar(): Artifact { + return JSON.parse(fs.readFileSync(sidecarPath(), "utf8")) as Artifact; +} +async function openSample(): Promise { + const uri = vscode.Uri.file(path.join(WS, DOC_REL)); + const doc = await vscode.workspace.openTextDocument(uri); + await vscode.window.showTextDocument(doc); + return doc; +} +async function getApi(): Promise { + const ext = vscode.extensions.getExtension("benstull.vscode-cowriting-plugin")!; + const api = (await ext.activate()) as CowritingApi; + assert.ok(api?.threadController, "extension should export threadController"); + return api; +} + +suite("F2 region-anchored threads (host E2E)", () => { + test("create on selection persists a thread sidecar", async () => { + const doc = await openSample(); + const api = await getApi(); + const start = doc.getText().indexOf("quick brown fox"); + const editor = vscode.window.activeTextEditor!; + editor.selection = new vscode.Selection(doc.positionAt(start), doc.positionAt(start + "quick brown fox".length)); + + const threadId = await api.threadController.createThreadOnSelection("First note"); + assert.ok(threadId, "createThreadOnSelection returns an id"); + assert.ok(fs.existsSync(sidecarPath()), "sidecar written to disk"); + + const art = readSidecar(); + assert.strictEqual(art.threads.length, 1); + assert.strictEqual(art.threads[0].status, "open"); + assert.strictEqual(art.threads[0].messages[0].body, "First note"); + const anchorId = art.threads[0].anchorId; + assert.strictEqual(art.anchors[anchorId].fingerprint.text, "quick brown fox"); + + const rendered = api.threadController.getRendered(DOC_REL); + assert.strictEqual(rendered.length, 1); + assert.strictEqual(rendered[0].orphaned, false); + }); + + test("reply appends and resolve flips status, both persisted", async () => { + const api = await getApi(); + const art0 = readSidecar(); + const threadId = art0.threads[0].id; + + api.threadController.reply({ thread: findVsThread(api, threadId), text: "A reply" } as any); + const art1 = readSidecar(); + assert.deepStrictEqual(art1.threads[0].messages.map((m) => m.body), ["First note", "A reply"]); + + setStatusViaApi(api, threadId, "resolved"); + const art2 = readSidecar(); + assert.strictEqual(art2.threads[0].status, "resolved"); + }); + + test("reload renders the persisted thread at its anchor", async () => { + const doc = await openSample(); + const api = await getApi(); + api.threadController.renderAll(doc); + const rendered = api.threadController.getRendered(DOC_REL); + assert.strictEqual(rendered.length, 1); + assert.strictEqual(rendered[0].orphaned, false); + const anchorLine = doc.positionAt(doc.getText().indexOf("quick brown fox")).line; + assert.strictEqual(rendered[0].range.startLine, anchorLine); + }); + + test("external edit that moves the text re-anchors; deleting it orphans (INV-1)", async () => { + const uri = vscode.Uri.file(path.join(WS, DOC_REL)); + const original = fs.readFileSync(uri.fsPath, "utf8"); + + // Move the anchored text down by prepending lines (external change). + fs.writeFileSync(uri.fsPath, "PREPENDED LINE ONE\nPREPENDED LINE TWO\n" + original, "utf8"); + let doc = await vscode.workspace.openTextDocument(uri); + const api = await getApi(); + api.threadController.renderAll(doc); + let rendered = api.threadController.getRendered(DOC_REL); + assert.strictEqual(rendered[0].orphaned, false, "still anchored after a move"); + const movedLine = doc.positionAt(doc.getText().indexOf("quick brown fox")).line; + assert.strictEqual(rendered[0].range.startLine, movedLine); + + // Now delete the anchored text entirely → must orphan, not move. + fs.writeFileSync(uri.fsPath, "PREPENDED LINE ONE\nThe slow grey cat\n" + original.replace("The quick brown fox jumps over the lazy dog.", "The slow grey cat naps."), "utf8"); + doc = await vscode.workspace.openTextDocument(uri); + api.threadController.renderAll(doc); + rendered = api.threadController.getRendered(DOC_REL); + assert.strictEqual(rendered[0].orphaned, true, "deleted anchor must orphan (INV-1)"); + }); +}); + +// --- helpers that reach the live vscode thread for reply/resolve --- +function findVsThread(api: CowritingApi, threadId: string): vscode.CommentThread { + const rendered = api.threadController.getRendered(DOC_REL); + assert.ok(rendered.some((r) => r.id === threadId), "thread is rendered"); + return (api.threadController as unknown as { docs: Map }> }).docs + .get(DOC_REL)!.vsThreads.get(threadId)!; +} +function setStatusViaApi(api: CowritingApi, threadId: string, status: "open" | "resolved"): void { + const vsThread = findVsThread(api, threadId); + void vscode.commands.executeCommand(status === "resolved" ? "cowriting.resolveThread" : "cowriting.reopenThread", vsThread); +} +``` + +Note: `findVsThread`/`setStatusViaApi` reach the controller's internal `docs` map for the reply/resolve handlers (which expect a live `vscode.CommentThread`). This is the spec §6.8 "drive the ThreadController and assert Comments-controller state" fallback. Keep the `docs` field accessible (it is a private field but reachable via the cast above; do **not** add a public getter solely for this unless typecheck forces it). + +- [ ] **Step 8: Add the E2E artifacts to `.gitignore`** + +Append to `.gitignore`: + +``` +# E2E host-test build output + throwaway sidecars +out/test/ +test/e2e/fixtures/workspace/.threads/ +``` + +- [ ] **Step 9: Compile the E2E suite (typecheck the host tests)** + +Run: `npm run pretest:e2e` +Expected: PASS — `npm run build` writes `out/extension.cjs`; `tsc -p tsconfig.e2e.json` emits `out/test/e2e/**/*.js` and `out/src/**/*.js` with no type errors. + +- [ ] **Step 10: Run the host E2E** + +Run: `npm run test:e2e` +Expected: `@vscode/test-electron` downloads a VS Code build (first run only), launches the Extension Development Host on the temp workspace, and the 4 suites pass: create-persists, reply+resolve, reload, re-anchor+orphan. + +If the sandbox cannot download VS Code (no network), record this as the one operator-run step: re-run `npm run test:e2e` on the workstation. Do **not** mark SLICE-5 done on an unrun suite — note it explicitly in the transcript's Deferred decisions and the finalize report. + +- [ ] **Step 11: Commit** + +```bash +git add package.json package-lock.json tsconfig.e2e.json test/e2e .gitignore +git commit -m "F2 SLICE-5: @vscode/test-electron host E2E (create→reload→re-anchor→orphan) (#4)" +``` + +--- + +## Task 6: README + full verification sweep + +**Files:** +- Modify: `README.md` + +- [ ] **Step 1: Add an F2 section to `README.md`** + +Append (after the existing POC content): + +```markdown +## F2 — Region-anchored threads (Feature #4) + +Attach durable, region-anchored discussion threads to any document. Threads +render in the native VS Code **Comments** gutter and persist as git-native +sidecars under `.threads/.json` (plain, diffable JSON — no server). + +- **Create:** select text → run **"Cowriting: Add Coauthoring Thread on Selection"** + (or the Comments gutter "+"). +- **Reply / Resolve:** use the native Comments reply box and the thread's + Resolve/Reopen actions. +- **Survives edits, reload, and external change (`git pull`):** a hybrid anchor + (durable content fingerprint + live offset tracking) re-resolves the thread. + If the anchored text can't be confidently re-found, the thread is shown as + **orphaned** at its last-known line — never silently moved. + +### Tests + +- `npm test` — vitest unit suite (schema, store, anchorer, thread mutations). +- `npm run test:e2e` — `@vscode/test-electron` host E2E + (create → reply → resolve → persist → reload → re-anchor → orphan). +``` + +- [ ] **Step 2: Full verification sweep** + +Run: `npm install && npm run typecheck && npm test && npm run build` +Expected: install OK; typecheck OK; all vitest suites pass; build writes `out/extension.cjs`. + +- [ ] **Step 3: Run the host E2E (if not already green)** + +Run: `npm run test:e2e` +Expected: host suites pass (or recorded as the operator-run step per SLICE-5 Step 10). + +- [ ] **Step 4: Commit** + +```bash +git add README.md +git commit -m "F2: document region-anchored threads + test commands (#4)" +``` + +--- + +## Manual / operator verification (cannot be fully automated in-session) + +- [ ] Press **F5** → Extension Development Host opens on a folder with a text doc, no activation error. +- [ ] Select a phrase → **"Cowriting: Add Coauthoring Thread on Selection"** → a Comments thread appears on the gutter; `.threads/.json` is created. +- [ ] Reply in the thread, then Resolve → the sidecar reflects the extra message and `status: "resolved"`. +- [ ] Close & reopen the folder → the thread reloads on the same span. +- [ ] Edit above the anchor (push it down) and save → the thread tracks to the new line. Delete the anchored text → the thread shows as **orphaned**, not moved (INV-1). + +This is F2's acceptance: region-anchored threads create/reply/resolve via native Comments, persist as git-native sidecars, and survive edits/reload/external change — orphaning rather than guessing — with unit + host E2E green (spec §7.3). + +--- + +## Spec coverage self-review + +- **SLICE-1** schema + CoauthorStore → Task 1. Shared `anchors`/`provenance` + `schemaVersion` + empty `attributions`/`proposals` extension points (INV-4) present in `model.ts`. Stable formatting (INV-2) tested. +- **SLICE-2** Anchorer → Task 2. Resolution ladder exact→context→lineHint→orphaned (INV-1/INV-3) + live `shift` tested. +- **SLICE-3** ThreadController via `vscode.comments` (create/reply/resolve/render) → Task 3, with vscode-free `threadModel` unit-tested. +- **SLICE-4** reload + FileSystemWatcher external re-anchor + orphaned surface → Task 3 (mechanics) + Task 4 (decision-logic tests). +- **SLICE-5** `@vscode/test-electron` E2E across create→persist→reload→re-anchor → Task 5. +- **INV-5** no live `@cline/sdk` turn / no credentials → nothing in F2 calls the SDK; `cline.ts` untouched. +- **PUC-1..4** → create (T3), reply/resolve (T3), reload (T3/T5), re-anchor/orphan (T3/T4/T5). +- UI surface ⇒ E2E host tests are first-class plan tasks → SLICE-5 is Task 5, not a follow-up (handbook §4 / spec §6.8, §7.2). diff --git a/package-lock.json b/package-lock.json index f68bcb4..14648ce 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,9 +12,13 @@ "@cline/sdk": "0.0.46" }, "devDependencies": { + "@types/mocha": "^10.0.7", "@types/node": "^22.0.0", "@types/vscode": "^1.90.0", + "@vscode/test-electron": "^2.4.0", "esbuild": "^0.23.0", + "glob": "^11.0.0", + "mocha": "^10.7.0", "typescript": "^5.5.0", "vitest": "^2.0.0" }, @@ -1335,6 +1339,16 @@ "hono": "^4" } }, + "node_modules/@isaacs/cliui": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-9.0.0.tgz", + "integrity": "sha512-AokJm4tuBHillT+FpMtxQ60n8ObyXBatq7jD2/JA9dxbDDokKQm8KMht5ibGzLVU9IJDIKK4TPKgMHEYMn3lMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, "node_modules/@jerome-benoit/sap-ai-provider": { "version": "4.6.9", "resolved": "https://registry.npmjs.org/@jerome-benoit/sap-ai-provider/-/sap-ai-provider-4.6.9.tgz", @@ -2824,6 +2838,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/mocha": { + "version": "10.0.10", + "resolved": "https://registry.npmjs.org/@types/mocha/-/mocha-10.0.10.tgz", + "integrity": "sha512-xPyYSz1cMPnJQhl0CLMH68j3gprKZaTjG3s5Vi+fDgx+uhG9NOXwbVt52eFS8ECyXhyKcjDLCBEqBExKuiZb7Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/node": { "version": "22.19.20", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.20.tgz", @@ -2968,6 +2989,47 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/@vscode/test-electron": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/@vscode/test-electron/-/test-electron-2.5.2.tgz", + "integrity": "sha512-8ukpxv4wYe0iWMRQU18jhzJOHkeGKbnw7xWRX3Zw1WJA4cEKbHcmmLPdPrPtL6rhDcrlCZN+xKRpv09n4gRHYg==", + "dev": true, + "license": "MIT", + "dependencies": { + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.5", + "jszip": "^3.10.1", + "ora": "^8.1.0", + "semver": "^7.6.2" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/@vscode/test-electron/node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/@vscode/test-electron/node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/accepts": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", @@ -3098,6 +3160,79 @@ } } }, + "node_modules/ansi-colors": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz", + "integrity": "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/ansi-styles/node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/ansi-styles/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/anynum": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/anynum/-/anynum-1.0.0.tgz", @@ -3110,6 +3245,13 @@ ], "license": "MIT" }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, "node_modules/asn1": { "version": "0.2.6", "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.6.tgz", @@ -3177,6 +3319,16 @@ "proxy-from-env": "^2.1.0" } }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, "node_modules/base64-js": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", @@ -3206,6 +3358,19 @@ "node": "*" } }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/body-parser": { "version": "2.2.2", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", @@ -3236,6 +3401,39 @@ "integrity": "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==", "license": "MIT" }, + "node_modules/brace-expansion": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browser-stdout": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/browser-stdout/-/browser-stdout-1.3.1.tgz", + "integrity": "sha512-qhAVI1+Av2X7qelOfAIYwXONood6XlZE/fXaBSmW/T5SzLAmCgzi+eiWE7fUvbHaeNBQH13UftjpXxsfLkMpgw==", + "dev": true, + "license": "ISC" + }, "node_modules/buffer-equal-constant-time": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", @@ -3290,6 +3488,19 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/camelcase": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", + "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/chai": { "version": "5.3.3", "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", @@ -3307,6 +3518,36 @@ "node": ">=18" } }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chalk/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/check-error": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", @@ -3317,6 +3558,117 @@ "node": ">= 16" } }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/cli-cursor": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz", + "integrity": "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==", + "dev": true, + "license": "MIT", + "dependencies": { + "restore-cursor": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-spinners": { + "version": "2.9.2", + "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.9.2.tgz", + "integrity": "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cliui": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-7.0.4.tgz", + "integrity": "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^7.0.0" + } + }, + "node_modules/cliui/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/cliui/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/clone": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz", @@ -3487,6 +3839,19 @@ } } }, + "node_modules/decamelize": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-4.0.0.tgz", + "integrity": "sha512-9iE1PgSik9HeIIw2JO94IidnE3eBoQrFJ3w7sFuzSX4DpmZ3v5sZpUiV5Swcf6mQEF+Y0ru8Neo+p+nyh2J+hQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/deep-eql": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", @@ -3515,6 +3880,16 @@ "node": ">= 0.8" } }, + "node_modules/diff": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/diff/-/diff-5.2.2.tgz", + "integrity": "sha512-vtcDfH3TOjP8UekytvnHH1o1P4FcUdt4eQ1Y+Abap1tk/OB2MWQvcwS2ClCd1zuIhc3JKOx6p3kod8Vfys3E+A==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, "node_modules/dify-ai-provider": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/dify-ai-provider/-/dify-ai-provider-1.1.0.tgz", @@ -3593,6 +3968,13 @@ "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", "license": "MIT" }, + "node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "dev": true, + "license": "MIT" + }, "node_modules/enabled": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/enabled/-/enabled-2.0.0.tgz", @@ -3700,12 +4082,35 @@ "@esbuild/win32-x64": "0.23.1" } }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/escape-html": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", "license": "MIT" }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/estree-walker": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", @@ -3920,6 +4325,19 @@ "node": "^12.20 || >= 14.13" } }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/finalhandler": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", @@ -3941,6 +4359,33 @@ "url": "https://opencollective.com/express" } }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/flat/-/flat-5.0.2.tgz", + "integrity": "sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ==", + "dev": true, + "license": "BSD-3-Clause", + "bin": { + "flat": "cli.js" + } + }, "node_modules/fn.name": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/fn.name/-/fn.name-1.1.0.tgz", @@ -3967,6 +4412,23 @@ } } }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "dev": true, + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/form-data": { "version": "4.0.5", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", @@ -4034,6 +4496,13 @@ "node": ">= 0.8" } }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "dev": true, + "license": "ISC" + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -4108,6 +4577,29 @@ "node": ">=18" } }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-east-asian-width": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz", + "integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/get-intrinsic": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", @@ -4145,6 +4637,44 @@ "node": ">= 0.4" } }, + "node_modules/glob": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-11.1.0.tgz", + "integrity": "sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "foreground-child": "^3.3.1", + "jackspeak": "^4.1.1", + "minimatch": "^10.1.1", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^2.0.0" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/google-auth-library": { "version": "10.7.0", "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.7.0.tgz", @@ -4183,6 +4713,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/has-symbols": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", @@ -4222,6 +4762,16 @@ "node": ">= 0.4" } }, + "node_modules/he": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", + "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", + "dev": true, + "license": "MIT", + "bin": { + "he": "bin/he" + } + }, "node_modules/hono": { "version": "4.12.25", "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.25.tgz", @@ -4251,6 +4801,30 @@ "url": "https://opencollective.com/express" } }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/http-proxy-agent/node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, "node_modules/https-proxy-agent": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", @@ -4280,6 +4854,25 @@ "url": "https://opencollective.com/express" } }, + "node_modules/immediate": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", + "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "dev": true, + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", @@ -4304,6 +4897,85 @@ "node": ">= 0.10" } }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-interactive": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-2.0.0.tgz", + "integrity": "sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-plain-obj": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-2.1.0.tgz", + "integrity": "sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/is-promise": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", @@ -4322,12 +4994,48 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-unicode-supported": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz", + "integrity": "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "dev": true, + "license": "MIT" + }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", "license": "ISC" }, + "node_modules/jackspeak": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-4.2.3.tgz", + "integrity": "sha512-ykkVRwrYvFm1nb2AJfKKYPr0emF6IiXDYUaFx4Zn9ZuIH7MrzEZ3sD5RlqGXNRpHtvUHJyOnCEFxOlNDtGo7wg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^9.0.0" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/jiti": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", @@ -4357,6 +5065,29 @@ "url": "https://github.com/sponsors/panva" } }, + "node_modules/js-yaml": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz", + "integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, "node_modules/json-bigint": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz", @@ -4434,6 +5165,52 @@ "npm": ">=6" } }, + "node_modules/jszip": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz", + "integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==", + "dev": true, + "license": "(MIT OR GPL-3.0-or-later)", + "dependencies": { + "lie": "~3.3.0", + "pako": "~1.0.2", + "readable-stream": "~2.3.6", + "setimmediate": "^1.0.5" + } + }, + "node_modules/jszip/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "dev": true, + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/jszip/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "dev": true, + "license": "MIT" + }, + "node_modules/jszip/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, "node_modules/jwa": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", @@ -4470,6 +5247,32 @@ "integrity": "sha512-Xq9nH7KlWZmXAtodXDDRE7vs6DU1gTU8zYDHDiWLSip45Egwq3plLHzPn27NgvzL2r1LMPC1vdqh98sQxtqj4A==", "license": "MIT" }, + "node_modules/lie": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", + "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "immediate": "~3.0.5" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/lodash.includes": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", @@ -4512,6 +5315,23 @@ "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", "license": "MIT" }, + "node_modules/log-symbols": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", + "integrity": "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.1.0", + "is-unicode-supported": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/logform": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/logform/-/logform-2.7.0.tgz", @@ -4542,6 +5362,16 @@ "dev": true, "license": "MIT" }, + "node_modules/lru-cache": { + "version": "11.5.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.1.tgz", + "integrity": "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -4607,6 +5437,132 @@ "url": "https://opencollective.com/express" } }, + "node_modules/mimic-function": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/mimic-function/-/mimic-function-5.0.1.tgz", + "integrity": "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/mocha": { + "version": "10.8.2", + "resolved": "https://registry.npmjs.org/mocha/-/mocha-10.8.2.tgz", + "integrity": "sha512-VZlYo/WE8t1tstuRmqgeyBgCbJc/lEdopaa+axcKzTBJ+UIdlAB9XnmvTCAH4pwR4ElNInaedhEBmZD8iCSVEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-colors": "^4.1.3", + "browser-stdout": "^1.3.1", + "chokidar": "^3.5.3", + "debug": "^4.3.5", + "diff": "^5.2.0", + "escape-string-regexp": "^4.0.0", + "find-up": "^5.0.0", + "glob": "^8.1.0", + "he": "^1.2.0", + "js-yaml": "^4.1.0", + "log-symbols": "^4.1.0", + "minimatch": "^5.1.6", + "ms": "^2.1.3", + "serialize-javascript": "^6.0.2", + "strip-json-comments": "^3.1.1", + "supports-color": "^8.1.1", + "workerpool": "^6.5.1", + "yargs": "^16.2.0", + "yargs-parser": "^20.2.9", + "yargs-unparser": "^2.0.0" + }, + "bin": { + "_mocha": "bin/_mocha", + "mocha": "bin/mocha.js" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/mocha/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/mocha/node_modules/brace-expansion": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz", + "integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/mocha/node_modules/glob": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-8.1.0.tgz", + "integrity": "sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^5.0.1", + "once": "^1.3.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/mocha/node_modules/minimatch": { + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz", + "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -4720,6 +5676,16 @@ "asn1": "^0.2.4" } }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -4771,6 +5737,22 @@ "fn.name": "1.x.x" } }, + "node_modules/onetime": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-7.0.0.tgz", + "integrity": "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-function": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/opossum": { "version": "9.0.0", "resolved": "https://registry.npmjs.org/opossum/-/opossum-9.0.0.tgz", @@ -4780,6 +5762,132 @@ "node": "^24 || ^22 || ^20" } }, + "node_modules/ora": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/ora/-/ora-8.2.0.tgz", + "integrity": "sha512-weP+BZ8MVNnlCm8c0Qdc1WSWq4Qn7I+9CJGm7Qali6g44e/PUzbjNqJX5NJ9ljlNMosfJvg1fKEGILklK9cwnw==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^5.3.0", + "cli-cursor": "^5.0.0", + "cli-spinners": "^2.9.2", + "is-interactive": "^2.0.0", + "is-unicode-supported": "^2.0.0", + "log-symbols": "^6.0.0", + "stdin-discarder": "^0.2.2", + "string-width": "^7.2.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ora/node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/ora/node_modules/is-unicode-supported": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-2.1.0.tgz", + "integrity": "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ora/node_modules/log-symbols": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-6.0.0.tgz", + "integrity": "sha512-i24m8rpwhmPIS4zscNzK6MSEhk0DUWa/8iYQWxhffV8jkI4Phvs3F+quL5xvS0gdQR0FyTCMMH33Y78dDTzzIw==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^5.3.0", + "is-unicode-supported": "^1.3.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ora/node_modules/log-symbols/node_modules/is-unicode-supported": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-1.3.0.tgz", + "integrity": "sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "dev": true, + "license": "BlueOak-1.0.0" + }, + "node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "dev": true, + "license": "(MIT AND Zlib)" + }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -4789,6 +5897,16 @@ "node": ">= 0.8" } }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/path-expression-matcher": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.5.0.tgz", @@ -4813,6 +5931,23 @@ "node": ">=8" } }, + "node_modules/path-scurry": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/path-to-regexp": { "version": "8.4.2", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", @@ -4847,6 +5982,19 @@ "dev": true, "license": "ISC" }, + "node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/pkce-challenge": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", @@ -4904,6 +6052,13 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "dev": true, + "license": "MIT" + }, "node_modules/protobufjs": { "version": "7.6.3", "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.3.tgz", @@ -4965,6 +6120,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/randombytes": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", + "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "^5.1.0" + } + }, "node_modules/range-parser": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", @@ -5003,6 +6168,29 @@ "node": ">= 6" } }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/require-from-string": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", @@ -5012,6 +6200,23 @@ "node": ">=0.10.0" } }, + "node_modules/restore-cursor": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz", + "integrity": "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==", + "dev": true, + "license": "MIT", + "dependencies": { + "onetime": "^7.0.0", + "signal-exit": "^4.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/retry": { "version": "0.13.1", "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", @@ -5155,6 +6360,16 @@ "url": "https://opencollective.com/express" } }, + "node_modules/serialize-javascript": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.2.tgz", + "integrity": "sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "randombytes": "^2.1.0" + } + }, "node_modules/serve-static": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", @@ -5174,6 +6389,13 @@ "url": "https://opencollective.com/express" } }, + "node_modules/setimmediate": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", + "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==", + "dev": true, + "license": "MIT" + }, "node_modules/setprototypeof": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", @@ -5280,6 +6502,19 @@ "dev": true, "license": "ISC" }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/simple-git": { "version": "3.36.0", "resolved": "https://registry.npmjs.org/simple-git/-/simple-git-3.36.0.tgz", @@ -5339,6 +6574,19 @@ "dev": true, "license": "MIT" }, + "node_modules/stdin-discarder": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/stdin-discarder/-/stdin-discarder-0.2.2.tgz", + "integrity": "sha512-UhDfHmA92YAlNnCfhmq0VeNL5bDbiZGg7sZ2IvPsXubGkiNa9EC+tUTsjBRsYUAz87btI6/1wf4XoVvQ3uRnmQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/string_decoder": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", @@ -5348,6 +6596,53 @@ "safe-buffer": "~5.2.0" } }, + "node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/strnum": { "version": "2.4.0", "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.4.0.tgz", @@ -5363,6 +6658,22 @@ "anynum": "^1.0.0" } }, + "node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, "node_modules/text-hex": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/text-hex/-/text-hex-1.0.0.tgz", @@ -5413,6 +6724,19 @@ "node": ">=14.0.0" } }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, "node_modules/toidentifier": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", @@ -6194,6 +7518,76 @@ "node": ">= 12.0.0" } }, + "node_modules/workerpool": { + "version": "6.5.1", + "resolved": "https://registry.npmjs.org/workerpool/-/workerpool-6.5.1.tgz", + "integrity": "sha512-Fs4dNYcsdpYSAfVxhnl1L5zTksjvOJxtC5hzMNl+1t9B8hTJTdKDyZ5ju7ztgPy+ft9tBFXoOlDNiOT9WUXZlA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/wrap-ansi/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", @@ -6236,6 +7630,16 @@ "node": ">=16.0.0" } }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, "node_modules/yaml": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", @@ -6251,6 +7655,109 @@ "url": "https://github.com/sponsors/eemeli" } }, + "node_modules/yargs": { + "version": "16.2.0", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-16.2.0.tgz", + "integrity": "sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^7.0.2", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.0", + "y18n": "^5.0.5", + "yargs-parser": "^20.2.2" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/yargs-parser": { + "version": "20.2.9", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.9.tgz", + "integrity": "sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yargs-unparser": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/yargs-unparser/-/yargs-unparser-2.0.0.tgz", + "integrity": "sha512-7pRTIA9Qc1caZ0bZ6RYRGbHJthJWuakf+WmHK0rVeLkNrrGhfoabBNdue6kdINI6r4if7ocq9aD/n7xwKOdzOA==", + "dev": true, + "license": "MIT", + "dependencies": { + "camelcase": "^6.0.0", + "decamelize": "^4.0.0", + "flat": "^5.0.2", + "is-plain-obj": "^2.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/yargs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/yargs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/zod": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", diff --git a/package.json b/package.json index 5e07a3c..81d8865 100644 --- a/package.json +++ b/package.json @@ -12,30 +12,61 @@ }, "categories": ["Other"], "main": "./out/extension.cjs", - "activationEvents": [], + "activationEvents": ["onStartupFinished"], "contributes": { "commands": [ { "command": "cowriting.showClineSdkInfo", "title": "Cowriting: Show Cline SDK Info", "category": "Cowriting" - } - ] + }, + { + "command": "cowriting.createThread", + "title": "Cowriting: Add Coauthoring Thread on Selection", + "category": "Cowriting" + }, + { "command": "cowriting.reply", "title": "Reply", "category": "Cowriting" }, + { "command": "cowriting.resolveThread", "title": "Resolve Thread", "category": "Cowriting" }, + { "command": "cowriting.reopenThread", "title": "Reopen Thread", "category": "Cowriting" } + ], + "menus": { + "comments/commentThread/context": [ + { "command": "cowriting.reply", "group": "inline", "when": "commentController == cowriting.threads" } + ], + "comments/commentThread/title": [ + { + "command": "cowriting.resolveThread", + "group": "inline", + "when": "commentController == cowriting.threads && commentThread =~ /^open$/" + }, + { + "command": "cowriting.reopenThread", + "group": "inline", + "when": "commentController == cowriting.threads && commentThread =~ /^resolved$/" + } + ] + } }, "scripts": { "build": "node esbuild.mjs", "watch": "node esbuild.mjs --watch", "typecheck": "tsc --noEmit", "test": "vitest run", + "pretest:e2e": "npm run build && tsc -p tsconfig.e2e.json", + "test:e2e": "node ./out/test/e2e/runTest.js", "vscode:prepublish": "node esbuild.mjs" }, "dependencies": { "@cline/sdk": "0.0.46" }, "devDependencies": { + "@types/mocha": "^10.0.7", "@types/node": "^22.0.0", "@types/vscode": "^1.90.0", + "@vscode/test-electron": "^2.4.0", "esbuild": "^0.23.0", + "glob": "^11.0.0", + "mocha": "^10.7.0", "typescript": "^5.5.0", "vitest": "^2.0.0" } diff --git a/src/anchorer.ts b/src/anchorer.ts new file mode 100644 index 0000000..e8f9162 --- /dev/null +++ b/src/anchorer.ts @@ -0,0 +1,113 @@ +/** + * Anchorer — hybrid anchoring (spec §6.5). The durable FINGERPRINT is the source + * of truth for an anchor's location (INV-3); live OFFSET ranges are a within- + * session optimization. The resolution ladder is: + * exact-unique → context-disambiguated → lineHint tiebreak → orphaned. + * It NEVER guesses on an unbreakable tie — it orphans instead (INV-1). + * + * vscode-free: operates on plain strings and character-offset ranges, so it is + * unit-testable in Node. The vscode layer converts OffsetRange <-> vscode.Range. + */ +import type { Fingerprint } from "./model"; + +export interface OffsetRange { + start: number; + end: number; +} + +/** A document edit: the half-open range [start,end) was replaced with text of `newLength`. */ +export interface TextEdit { + start: number; + end: number; + newLength: number; +} + +const MAX_CTX_CHARS = 120; +const MAX_CTX_LINES = 3; + +function lineNumberAt(doc: string, offset: number): number { + let line = 0; + const bound = Math.min(offset, doc.length); + for (let i = 0; i < bound; i++) if (doc.charCodeAt(i) === 10) line++; + return line; +} + +function leadingContext(doc: string, start: number): string { + let s = doc.slice(Math.max(0, start - MAX_CTX_CHARS), start); + const lines = s.split("\n"); + if (lines.length > MAX_CTX_LINES) s = lines.slice(lines.length - MAX_CTX_LINES).join("\n"); + return s; +} + +function trailingContext(doc: string, end: number): string { + let s = doc.slice(end, Math.min(doc.length, end + MAX_CTX_CHARS)); + const lines = s.split("\n"); + if (lines.length > MAX_CTX_LINES) s = lines.slice(0, MAX_CTX_LINES).join("\n"); + return s; +} + +export function buildFingerprint(docText: string, range: OffsetRange): Fingerprint { + return { + text: docText.slice(range.start, range.end), + before: leadingContext(docText, range.start), + after: trailingContext(docText, range.end), + lineHint: lineNumberAt(docText, range.start), + }; +} + +function allIndexesOf(hay: string, needle: string): number[] { + if (needle.length === 0) return []; + const out: number[] = []; + let from = 0; + let idx = hay.indexOf(needle, from); + while (idx !== -1) { + out.push(idx); + from = idx + 1; + idx = hay.indexOf(needle, from); + } + return out; +} + +function contextMatches(doc: string, i: number, fp: Fingerprint): boolean { + const beforeOk = fp.before.length === 0 || doc.slice(Math.max(0, i - fp.before.length), i) === fp.before; + const afterStart = i + fp.text.length; + const afterOk = fp.after.length === 0 || doc.slice(afterStart, afterStart + fp.after.length) === fp.after; + return beforeOk && afterOk; +} + +const rangeAt = (i: number, text: string): OffsetRange => ({ start: i, end: i + text.length }); + +/** + * Re-resolve a fingerprint against current text. Returns the resolved range or + * "orphaned" when no confident, unique match exists (INV-1). + */ +export function resolve(docText: string, fp: Fingerprint): OffsetRange | "orphaned" { + const occ = allIndexesOf(docText, fp.text); + if (occ.length === 0) return "orphaned"; + if (occ.length === 1) return rangeAt(occ[0], fp.text); + + // Multiple exact matches: try context disambiguation. + const ctx = occ.filter((i) => contextMatches(docText, i, fp)); + if (ctx.length === 1) return rangeAt(ctx[0], fp.text); + + // Still ambiguous: break ties by proximity to lineHint within the best pool. + const pool = ctx.length > 0 ? ctx : occ; + const dists = pool.map((i) => ({ i, d: Math.abs(lineNumberAt(docText, i) - fp.lineHint) })); + const min = Math.min(...dists.map((x) => x.d)); + const closest = dists.filter((x) => x.d === min); + if (closest.length === 1) return rangeAt(closest[0].i, fp.text); + + // Unbreakable tie — refuse to guess. + return "orphaned"; +} + +/** Maintain a live range across an in-session edit (spec §6.4 `shift`). */ +export function shift(range: OffsetRange, edit: TextEdit): OffsetRange { + const delta = edit.newLength - (edit.end - edit.start); + const point = (p: number): number => { + if (p <= edit.start) return p; + if (p >= edit.end) return p + delta; + return edit.start; // inside the replaced span — clamp to its start + }; + return { start: point(range.start), end: point(range.end) }; +} diff --git a/src/extension.ts b/src/extension.ts index 1f10d70..8671a27 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -1,40 +1,56 @@ import * as vscode from "vscode"; import { fetchSdkSummary } from "./cline"; +import { CoauthorStore } from "./store"; +import { ThreadController } from "./threadController"; const CHANNEL_NAME = "Cowriting (Cline SDK)"; -export function activate(context: vscode.ExtensionContext): void { +export interface CowritingApi { + threadController: ThreadController; +} + +export function activate(context: vscode.ExtensionContext): CowritingApi | undefined { + // --- POC command (Feature #2), unchanged --- const output = vscode.window.createOutputChannel(CHANNEL_NAME); context.subscriptions.push(output); - - const command = vscode.commands.registerCommand( - "cowriting.showClineSdkInfo", - async () => { + context.subscriptions.push( + vscode.commands.registerCommand("cowriting.showClineSdkInfo", async () => { try { const summary = await fetchSdkSummary(); output.clear(); output.appendLine(`@cline/sdk version: ${summary.version}`); output.appendLine(`Builtin tools (${summary.tools.length}):`); - for (const tool of summary.tools) { - output.appendLine(` • ${tool.id} — ${tool.description}`); - } + for (const tool of summary.tools) output.appendLine(` • ${tool.id} — ${tool.description}`); output.show(true); await vscode.window.showInformationMessage( - `Cline SDK ${summary.version} loaded — ${summary.tools.length} builtin tools. See the "${CHANNEL_NAME}" output channel.` + `Cline SDK ${summary.version} loaded — ${summary.tools.length} builtin tools. See the "${CHANNEL_NAME}" output channel.`, ); } catch (err) { const message = err instanceof Error ? err.message : String(err); output.appendLine(`Failed to drive @cline/sdk: ${message}`); output.show(true); - await vscode.window.showErrorMessage( - `Cowriting: failed to load @cline/sdk — ${message}` - ); + await vscode.window.showErrorMessage(`Cowriting: failed to load @cline/sdk — ${message}`); } - } + }), ); - context.subscriptions.push(command); + + // --- F2: region-anchored threads (Feature #4) --- + const root = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath; + if (!root) return undefined; // no workspace → nothing to anchor against + const store = new CoauthorStore(root); + const threadController = new ThreadController(store, root); + context.subscriptions.push(threadController); + + // Render threads for already-open editors, and on future opens. + const renderIfOpen = (doc: vscode.TextDocument) => { + if (doc.uri.scheme === "file" && doc.uri.fsPath.startsWith(root)) threadController.renderAll(doc); + }; + vscode.workspace.textDocuments.forEach(renderIfOpen); + context.subscriptions.push(vscode.workspace.onDidOpenTextDocument(renderIfOpen)); + + return { threadController }; } export function deactivate(): void { - // Nothing to clean up beyond the disposables registered on the context. + // Disposables registered on the context handle cleanup. } diff --git a/src/model.ts b/src/model.ts new file mode 100644 index 0000000..c1113de --- /dev/null +++ b/src/model.ts @@ -0,0 +1,124 @@ +/** + * Versioned coauthoring artifact schema (spec §6.3) — the shared git-native + * envelope under every coauthoring capability. `anchors` and the `Provenance` + * author field are SHARED primitives (INV-4): F3 (attributions) and F4 + * (proposals) add arrays that reference the same shapes without a format break. + * + * vscode-free and pure, so it is unit-testable in Node (mirrors src/cline.ts). + */ +import { randomUUID } from "node:crypto"; + +export const SCHEMA_VERSION = 1 as const; + +/** Anchored text + bounded context + a line tie-breaker (spec §6.3). */ +export interface Fingerprint { + text: string; + /** <= ~3 lines / 120 chars leading context. */ + before: string; + /** <= ~3 lines / 120 chars trailing context. */ + after: string; + /** 0-based line of the anchor start; a tie-breaker, NOT truth (INV-3). */ + lineHint: number; +} + +export interface Anchor { + fingerprint: Fingerprint; +} + +/** The reusable author field (spec §6.3). `agent` only present when kind=agent. */ +export type Provenance = + | { kind: "human"; id: string } + | { kind: "agent"; id: string; agent: { sdk: string; model: string; sessionId: string } }; + +export interface Message { + id: string; + author: Provenance; + body: string; + /** ISO-8601. */ + createdAt: string; +} + +export type ThreadStatus = "open" | "resolved"; + +export interface Thread { + id: string; + anchorId: string; + status: ThreadStatus; + messages: Message[]; +} + +export interface Artifact { + schemaVersion: number; + /** repo-relative path; the sidecar key. */ + document: { path: string }; + /** SHARED primitive (INV-4). */ + anchors: Record; + threads: Thread[]; + /** F3 extension point — reuses anchors[]; not implemented in F2. */ + attributions: unknown[]; + /** F4 extension point — reuses anchors[] + provenance; not in F2. */ + proposals: unknown[]; +} + +export function emptyArtifact(docPath: string): Artifact { + return { + schemaVersion: SCHEMA_VERSION, + document: { path: docPath }, + anchors: {}, + threads: [], + attributions: [], + proposals: [], + }; +} + +/** Stable, generated id (spec §6.3). */ +export function newId(prefix: string): string { + return `${prefix}_${randomUUID()}`; +} + +function serializeProvenance(p: Provenance): Record { + return p.kind === "agent" + ? { kind: p.kind, id: p.id, agent: { sdk: p.agent.sdk, model: p.agent.model, sessionId: p.agent.sessionId } } + : { kind: p.kind, id: p.id }; +} + +/** + * Pretty-printed JSON with STABLE key ordering (anchors sorted by id), so + * sidecar diffs stay minimal and merge-friendly (spec §6.3, INV-2). Always + * ends with a trailing newline. + */ +export function serializeArtifact(a: Artifact): string { + const canonical = { + schemaVersion: a.schemaVersion, + document: { path: a.document.path }, + anchors: Object.fromEntries( + Object.keys(a.anchors) + .sort() + .map((k) => [ + k, + { + fingerprint: { + text: a.anchors[k].fingerprint.text, + before: a.anchors[k].fingerprint.before, + after: a.anchors[k].fingerprint.after, + lineHint: a.anchors[k].fingerprint.lineHint, + }, + }, + ]), + ), + threads: a.threads.map((t) => ({ + id: t.id, + anchorId: t.anchorId, + status: t.status, + messages: t.messages.map((m) => ({ + id: m.id, + author: serializeProvenance(m.author), + body: m.body, + createdAt: m.createdAt, + })), + })), + attributions: a.attributions, + proposals: a.proposals, + }; + return JSON.stringify(canonical, null, 2) + "\n"; +} diff --git a/src/store.ts b/src/store.ts new file mode 100644 index 0000000..f96acd3 --- /dev/null +++ b/src/store.ts @@ -0,0 +1,31 @@ +/** + * CoauthorStore — load/save the per-document `.threads/` sidecar (spec §6.4). + * Git-native, serverless, plain pretty JSON (INV-2). vscode-free (Node fs only), + * so it is unit-testable; the FileSystemWatcher that triggers re-anchoring on + * external change is wired in the vscode layer (ThreadController, SLICE-4). + */ +import * as fs from "node:fs"; +import * as path from "node:path"; +import { serializeArtifact, type Artifact } from "./model"; + +export class CoauthorStore { + /** @param rootDir absolute workspace-folder root that owns the `.threads/` tree. */ + constructor(private readonly rootDir: string) {} + + /** `.threads/.json` (spec §6.3). */ + sidecarPath(docPath: string): string { + return path.join(this.rootDir, ".threads", `${docPath}.json`); + } + + load(docPath: string): Artifact | null { + const p = this.sidecarPath(docPath); + if (!fs.existsSync(p)) return null; + return JSON.parse(fs.readFileSync(p, "utf8")) as Artifact; + } + + save(docPath: string, artifact: Artifact): void { + const p = this.sidecarPath(docPath); + fs.mkdirSync(path.dirname(p), { recursive: true }); + fs.writeFileSync(p, serializeArtifact(artifact), "utf8"); + } +} diff --git a/src/threadController.ts b/src/threadController.ts new file mode 100644 index 0000000..0631440 --- /dev/null +++ b/src/threadController.ts @@ -0,0 +1,291 @@ +/** + * ThreadController — the thin editor-facing layer (spec §6.4). Wires + * CoauthorStore + Anchorer + threadModel to vscode.comments: create-on- + * selection, reply, resolve, render, live-range tracking, reload, and + * FileSystemWatcher-driven external re-anchoring (SLICE-4). Threads are NEVER + * silently moved — an unresolvable anchor renders as an orphaned thread (INV-1). + */ +import * as vscode from "vscode"; +import { CoauthorStore } from "./store"; +import { emptyArtifact, type Artifact, type Provenance } from "./model"; +import { buildFingerprint, resolve, shift, type OffsetRange } from "./anchorer"; +import { addThread, appendMessage, setStatus } from "./threadModel"; + +/** Test-facing snapshot of what is currently rendered for a document. */ +export interface RenderedThread { + id: string; + status: "open" | "resolved"; + orphaned: boolean; + range: { startLine: number; endLine: number }; +} + +interface DocState { + docPath: string; + uri: vscode.Uri; + artifact: Artifact; + /** thread id -> vscode thread. */ + vsThreads: Map; + /** thread id -> live offset range (within-session optimization, INV-3). */ + live: Map; + /** thread id -> orphaned flag for the current render. */ + orphaned: Map; +} + +export class ThreadController implements vscode.Disposable { + private readonly controller: vscode.CommentController; + private readonly disposables: vscode.Disposable[] = []; + private readonly docs = new Map(); // keyed by docPath + /** sidecar paths we just wrote, to ignore our own watcher events. */ + private readonly selfWrites = new Set(); + + constructor(private readonly store: CoauthorStore, private readonly rootDir: string) { + this.controller = vscode.comments.createCommentController("cowriting.threads", "Coauthoring Threads"); + this.controller.commentingRangeProvider = { + provideCommentingRanges: (document) => { + if (!this.isInRoot(document.uri)) return []; + return [new vscode.Range(0, 0, Math.max(0, document.lineCount - 1), 0)]; + }, + }; + this.disposables.push(this.controller); + + this.disposables.push( + vscode.commands.registerCommand("cowriting.createThread", () => this.createThreadOnSelection()), + vscode.commands.registerCommand("cowriting.reply", (r: vscode.CommentReply) => this.reply(r)), + vscode.commands.registerCommand("cowriting.resolveThread", (t: vscode.CommentThread) => + this.setThreadStatus(t, "resolved"), + ), + vscode.commands.registerCommand("cowriting.reopenThread", (t: vscode.CommentThread) => + this.setThreadStatus(t, "open"), + ), + vscode.workspace.onDidChangeTextDocument((e) => this.onDidChange(e)), + vscode.workspace.onDidSaveTextDocument((d) => this.onDidSave(d)), + ); + + // Re-anchor on external change to any sidecar (a git pull, a manual edit). + const watcher = vscode.workspace.createFileSystemWatcher("**/.threads/**/*.json"); + const onSidecar = (uri: vscode.Uri) => this.onExternalSidecarChange(uri); + watcher.onDidChange(onSidecar); + watcher.onDidCreate(onSidecar); + this.disposables.push(watcher); + } + + private isInRoot(uri: vscode.Uri): boolean { + return uri.scheme === "file" && uri.fsPath.startsWith(this.rootDir); + } + + private docPathOf(uri: vscode.Uri): string { + return vscode.workspace.asRelativePath(uri, false); + } + + private currentAuthor(): Provenance { + const id = vscode.workspace.getConfiguration("git").get("user.name") || process.env.USER || "human"; + return { kind: "human", id }; + } + + private persist(state: DocState): void { + this.selfWrites.add(this.store.sidecarPath(state.docPath)); + this.store.save(state.docPath, state.artifact); + } + + private ensureState(document: vscode.TextDocument): DocState { + const docPath = this.docPathOf(document.uri); + let state = this.docs.get(docPath); + if (!state) { + state = { + docPath, + uri: document.uri, + artifact: this.store.load(docPath) ?? emptyArtifact(docPath), + vsThreads: new Map(), + live: new Map(), + orphaned: new Map(), + }; + this.docs.set(docPath, state); + } + return state; + } + + // ---- PUC-1: create on selection ------------------------------------------------- + + async createThreadOnSelection(firstBody = "New thread"): Promise { + const editor = vscode.window.activeTextEditor; + if (!editor || editor.selection.isEmpty || !this.isInRoot(editor.document.uri)) return undefined; + const document = editor.document; + const state = this.ensureState(document); + const offsets: OffsetRange = { + start: document.offsetAt(editor.selection.start), + end: document.offsetAt(editor.selection.end), + }; + const fp = buildFingerprint(document.getText(), offsets); + const { threadId } = addThread(state.artifact, fp, { author: this.currentAuthor(), body: firstBody }); + this.persist(state); + this.renderThread(document, state, threadId, offsets, false); + return threadId; + } + + // ---- PUC-2: reply / resolve ----------------------------------------------------- + + reply(r: vscode.CommentReply): void { + const threadId = this.threadIdOf(r.thread); + const state = this.stateOfThread(r.thread); + if (!threadId || !state) return; + appendMessage(state.artifact, threadId, { author: this.currentAuthor(), body: r.text }); + this.persist(state); + this.refreshComments(r.thread, state, threadId); + } + + private setThreadStatus(vsThread: vscode.CommentThread, status: "open" | "resolved"): void { + const threadId = this.threadIdOf(vsThread); + const state = this.stateOfThread(vsThread); + if (!threadId || !state) return; + setStatus(state.artifact, threadId, status); + this.persist(state); + vsThread.state = status === "resolved" ? vscode.CommentThreadState.Resolved : vscode.CommentThreadState.Unresolved; + vsThread.contextValue = status; + } + + // ---- PUC-3/4: render, reload, re-anchor ----------------------------------------- + + /** Load + (re)render every thread for a document at its resolved anchor (or orphaned). */ + renderAll(document: vscode.TextDocument): void { + const docPath = this.docPathOf(document.uri); + const state = this.ensureState(document); + // fresh artifact from disk (reload / external change) + state.artifact = this.store.load(docPath) ?? emptyArtifact(docPath); + for (const vsThread of state.vsThreads.values()) vsThread.dispose(); + state.vsThreads.clear(); + state.live.clear(); + state.orphaned.clear(); + const text = document.getText(); + for (const thread of state.artifact.threads) { + const fp = state.artifact.anchors[thread.anchorId]?.fingerprint; + const resolved = fp ? resolve(text, fp) : "orphaned"; + if (resolved === "orphaned") { + const line = fp ? Math.min(fp.lineHint, Math.max(0, document.lineCount - 1)) : 0; + const off = document.offsetAt(new vscode.Position(line, 0)); + this.renderThread(document, state, thread.id, { start: off, end: off }, true); + } else { + this.renderThread(document, state, thread.id, resolved, false); + } + } + } + + private onExternalSidecarChange(uri: vscode.Uri): void { + const p = uri.fsPath; + if (this.selfWrites.has(p)) { + this.selfWrites.delete(p); + return; // our own write + } + for (const state of this.docs.values()) { + if (this.store.sidecarPath(state.docPath) === p) { + const doc = vscode.workspace.textDocuments.find((d) => this.docPathOf(d.uri) === state.docPath); + if (doc) this.renderAll(doc); + } + } + } + + private onDidChange(e: vscode.TextDocumentChangeEvent): void { + const state = this.docs.get(this.docPathOf(e.document.uri)); + if (!state || state.live.size === 0) return; + for (const change of e.contentChanges) { + const edit = { start: change.rangeOffset, end: change.rangeOffset + change.rangeLength, newLength: change.text.length }; + for (const [id, range] of state.live) { + const next = shift(range, edit); + state.live.set(id, next); + const vsThread = state.vsThreads.get(id); + if (vsThread && !state.orphaned.get(id)) { + vsThread.range = new vscode.Range(e.document.positionAt(next.start), e.document.positionAt(next.end)); + } + } + } + } + + private onDidSave(document: vscode.TextDocument): void { + const state = this.docs.get(this.docPathOf(document.uri)); + if (!state || state.live.size === 0) return; + const text = document.getText(); + let changed = false; + for (const thread of state.artifact.threads) { + if (state.orphaned.get(thread.id)) continue; + const range = state.live.get(thread.id); + if (!range) continue; + state.artifact.anchors[thread.anchorId] = { fingerprint: buildFingerprint(text, range) }; + changed = true; + } + if (changed) this.persist(state); + } + + // ---- rendering helpers ---------------------------------------------------------- + + private renderThread( + document: vscode.TextDocument, + state: DocState, + threadId: string, + offsets: OffsetRange, + orphaned: boolean, + ): void { + const thread = state.artifact.threads.find((t) => t.id === threadId)!; + const range = new vscode.Range(document.positionAt(offsets.start), document.positionAt(offsets.end)); + const vsThread = this.controller.createCommentThread( + document.uri, + range, + thread.messages.map((m) => this.toComment(m.body, m.author.id)), + ); + vsThread.label = orphaned ? "⚠ Orphaned thread (anchor not found)" : undefined; + vsThread.contextValue = orphaned ? "orphaned" : thread.status; + vsThread.state = thread.status === "resolved" ? vscode.CommentThreadState.Resolved : vscode.CommentThreadState.Unresolved; + vsThread.collapsibleState = vscode.CommentThreadCollapsibleState.Collapsed; + state.vsThreads.set(threadId, vsThread); + state.live.set(threadId, offsets); + state.orphaned.set(threadId, orphaned); + } + + private refreshComments(vsThread: vscode.CommentThread, state: DocState, threadId: string): void { + const thread = state.artifact.threads.find((t) => t.id === threadId)!; + vsThread.comments = thread.messages.map((m) => this.toComment(m.body, m.author.id)); + } + + private toComment(body: string, authorName: string): vscode.Comment { + return { + body: new vscode.MarkdownString(body), + mode: vscode.CommentMode.Preview, + author: { name: authorName }, + }; + } + + private threadIdOf(vsThread: vscode.CommentThread): string | undefined { + for (const state of this.docs.values()) { + for (const [id, t] of state.vsThreads) if (t === vsThread) return id; + } + return undefined; + } + + private stateOfThread(vsThread: vscode.CommentThread): DocState | undefined { + for (const state of this.docs.values()) { + for (const t of state.vsThreads.values()) if (t === vsThread) return state; + } + return undefined; + } + + // ---- test-facing surface -------------------------------------------------------- + + getRendered(docPath: string): RenderedThread[] { + const state = this.docs.get(docPath); + if (!state) return []; + const out: RenderedThread[] = []; + for (const [id, vsThread] of state.vsThreads) { + const t = state.artifact.threads.find((x) => x.id === id)!; + const r = vsThread.range; + out.push({ + id, + status: t.status, + orphaned: !!state.orphaned.get(id), + range: { startLine: r ? r.start.line : 0, endLine: r ? r.end.line : 0 }, + }); + } + return out; + } + + dispose(): void { + for (const d of this.disposables) d.dispose(); + } +} diff --git a/src/threadModel.ts b/src/threadModel.ts new file mode 100644 index 0000000..cded93a --- /dev/null +++ b/src/threadModel.ts @@ -0,0 +1,48 @@ +/** + * vscode-free mutations on the Artifact (spec §6.5). Keeping thread logic here + * (not in the vscode controller) keeps it unit-testable; ThreadController is a + * thin adapter that calls these and persists via CoauthorStore. + */ +import { + newId, + type Artifact, + type Fingerprint, + type Provenance, + type ThreadStatus, +} from "./model"; + +export interface NewMessage { + author: Provenance; + body: string; +} + +function message(m: NewMessage) { + return { id: newId("m"), author: m.author, body: m.body, createdAt: new Date().toISOString() }; +} + +/** Lazily-created anchor + thread for a new region discussion (PUC-1). */ +export function addThread( + artifact: Artifact, + fingerprint: Fingerprint, + first: NewMessage, +): { threadId: string; anchorId: string } { + const anchorId = newId("a"); + const threadId = newId("t"); + artifact.anchors[anchorId] = { fingerprint }; + artifact.threads.push({ id: threadId, anchorId, status: "open", messages: [message(first)] }); + return { threadId, anchorId }; +} + +/** Append a reply (PUC-2). Throws if the thread is unknown. */ +export function appendMessage(artifact: Artifact, threadId: string, m: NewMessage): void { + const t = artifact.threads.find((x) => x.id === threadId); + if (!t) throw new Error(`unknown thread id: ${threadId}`); + t.messages.push(message(m)); +} + +/** Resolve/reopen a thread (PUC-2). Throws if the thread is unknown. */ +export function setStatus(artifact: Artifact, threadId: string, status: ThreadStatus): void { + const t = artifact.threads.find((x) => x.id === threadId); + if (!t) throw new Error(`unknown thread id: ${threadId}`); + t.status = status; +} diff --git a/test/anchorer.test.ts b/test/anchorer.test.ts new file mode 100644 index 0000000..852ddff --- /dev/null +++ b/test/anchorer.test.ts @@ -0,0 +1,69 @@ +import { describe, it, expect } from "vitest"; +import { buildFingerprint, resolve, shift, type OffsetRange } from "../src/anchorer"; + +const DOC = "line0\nline1 needle here\nline2\nneedle again on line3\n"; + +describe("buildFingerprint", () => { + it("captures the exact text, bounded context, and 0-based lineHint", () => { + const start = DOC.indexOf("needle here"); + const range: OffsetRange = { start, end: start + "needle".length }; + const fp = buildFingerprint(DOC, range); + expect(fp.text).toBe("needle"); + expect(fp.lineHint).toBe(1); + expect(fp.before.endsWith("line1 ")).toBe(true); + expect(fp.after.startsWith(" here")).toBe(true); + expect(fp.before.length).toBeLessThanOrEqual(120); + expect(fp.after.length).toBeLessThanOrEqual(120); + }); +}); + +describe("resolve", () => { + it("exact-unique: returns the single occurrence's range", () => { + const fp = { text: "line2", before: "", after: "", lineHint: 0 }; + const start = DOC.indexOf("line2"); + expect(resolve(DOC, fp)).toEqual({ start, end: start + 5 }); + }); + + it("context-disambiguated: picks the occurrence whose before/after match", () => { + const fp = { text: "needle", before: "line1 ", after: " here", lineHint: 99 }; + const start = DOC.indexOf("needle here"); + expect(resolve(DOC, fp)).toEqual({ start, end: start + 6 }); + }); + + it("lineHint tiebreak: when context does not disambiguate, the closest line wins", () => { + // both occurrences of 'needle', no usable context, hint points at line 3 + const fp = { text: "needle", before: "", after: "", lineHint: 3 }; + const start = DOC.indexOf("needle again"); + expect(resolve(DOC, fp)).toEqual({ start, end: start + 6 }); + }); + + it("orphaned: returns 'orphaned' when the text is gone", () => { + const fp = { text: "absent-text", before: "", after: "", lineHint: 0 }; + expect(resolve(DOC, fp)).toBe("orphaned"); + }); + + it("orphaned: returns 'orphaned' rather than guessing on an unbreakable tie (INV-1)", () => { + const doc = "needle\n....\nneedle\n"; // two identical occurrences, equidistant from hint + const fp = { text: "needle", before: "", after: "", lineHint: 1 }; + expect(resolve(doc, fp)).toBe("orphaned"); + }); +}); + +describe("shift", () => { + it("edit entirely before the range shifts both endpoints by the delta", () => { + const r: OffsetRange = { start: 10, end: 15 }; + // insert 2 chars at offset 0 (replace [0,0) with len 2) + expect(shift(r, { start: 0, end: 0, newLength: 2 })).toEqual({ start: 12, end: 17 }); + }); + + it("edit entirely after the range leaves it unchanged", () => { + const r: OffsetRange = { start: 2, end: 5 }; + expect(shift(r, { start: 10, end: 12, newLength: 0 })).toEqual({ start: 2, end: 5 }); + }); + + it("edit overlapping the range clamps the touched endpoints to the edit start", () => { + const r: OffsetRange = { start: 5, end: 10 }; + // replace [3,7) with 1 char (delta = -3) + expect(shift(r, { start: 3, end: 7, newLength: 1 })).toEqual({ start: 3, end: 7 }); + }); +}); diff --git a/test/e2e/fixtures/workspace/docs/sample.md b/test/e2e/fixtures/workspace/docs/sample.md new file mode 100644 index 0000000..417d9ff --- /dev/null +++ b/test/e2e/fixtures/workspace/docs/sample.md @@ -0,0 +1,7 @@ +# Sample Document + +The quick brown fox jumps over the lazy dog. + +A second paragraph with the phrase anchor target inside it. + +Closing line. diff --git a/test/e2e/runTest.ts b/test/e2e/runTest.ts new file mode 100644 index 0000000..3b80141 --- /dev/null +++ b/test/e2e/runTest.ts @@ -0,0 +1,31 @@ +import * as path from "path"; +import * as os from "os"; +import * as fs from "fs"; +import { runTests } from "@vscode/test-electron"; + +async function main(): Promise { + const projectRoot = path.resolve(__dirname, "../../../"); + const extensionDevelopmentPath = projectRoot; + const extensionTestsPath = path.resolve(__dirname, "./suite/index"); + + // Copy the fixture workspace to a temp dir so test writes are throwaway. + const fixture = path.resolve(projectRoot, "test/e2e/fixtures/workspace"); + const workspace = fs.mkdtempSync(path.join(os.tmpdir(), "cowriting-e2e-")); + fs.cpSync(fixture, workspace, { recursive: true }); + + try { + await runTests({ + extensionDevelopmentPath, + extensionTestsPath, + launchArgs: [workspace, "--disable-extensions"], + extensionTestsEnv: { E2E_WORKSPACE: workspace }, + }); + } finally { + fs.rmSync(workspace, { recursive: true, force: true }); + } +} + +main().catch((err) => { + console.error("E2E run failed:", err); + process.exit(1); +}); diff --git a/test/e2e/suite/index.ts b/test/e2e/suite/index.ts new file mode 100644 index 0000000..9f0507c --- /dev/null +++ b/test/e2e/suite/index.ts @@ -0,0 +1,13 @@ +import * as path from "path"; +import Mocha from "mocha"; +import { glob } from "glob"; + +export async function run(): Promise { + const mocha = new Mocha({ ui: "tdd", color: true, timeout: 60000 }); + const testsRoot = path.resolve(__dirname); + const files = await glob("**/*.test.js", { cwd: testsRoot }); + for (const f of files) mocha.addFile(path.resolve(testsRoot, f)); + await new Promise((resolve, reject) => { + mocha.run((failures) => (failures > 0 ? reject(new Error(`${failures} E2E test(s) failed`)) : resolve())); + }); +} diff --git a/test/e2e/suite/threads.test.ts b/test/e2e/suite/threads.test.ts new file mode 100644 index 0000000..5b43dfe --- /dev/null +++ b/test/e2e/suite/threads.test.ts @@ -0,0 +1,139 @@ +import * as assert from "assert"; +import * as fs from "fs"; +import * as path from "path"; +import * as vscode from "vscode"; +import type { CowritingApi } from "../../../src/extension"; +import type { Artifact } from "../../../src/model"; + +const WS = process.env.E2E_WORKSPACE!; +const DOC_REL = "docs/sample.md"; + +function sidecarPath(): string { + return path.join(WS, ".threads", "docs", "sample.md.json"); +} +function readSidecar(): Artifact { + return JSON.parse(fs.readFileSync(sidecarPath(), "utf8")) as Artifact; +} +async function openSample(): Promise { + const uri = vscode.Uri.file(path.join(WS, DOC_REL)); + const doc = await vscode.workspace.openTextDocument(uri); + await vscode.window.showTextDocument(doc); + return doc; +} + +// Simulate an EXTERNAL change to the document (e.g. a git pull): write the file +// on disk, then force the open editor buffer to reload from disk with `revert` +// (openTextDocument otherwise returns VS Code's cached in-memory buffer). +async function externalWriteAndReload(uri: vscode.Uri, content: string): Promise { + fs.writeFileSync(uri.fsPath, content, "utf8"); + const doc = await vscode.workspace.openTextDocument(uri); + await vscode.window.showTextDocument(doc); + await vscode.commands.executeCommand("workbench.action.files.revert"); + return doc; +} +async function getApi(): Promise { + const ext = vscode.extensions.getExtension("benstull.vscode-cowriting-plugin")!; + const api = (await ext.activate()) as CowritingApi; + assert.ok(api?.threadController, "extension should export threadController"); + return api; +} + +// Reaches the controller's internal docs map for reply/resolve handlers (which +// expect a live vscode.CommentThread). Spec §6.8 "drive the ThreadController and +// assert Comments-controller state" fallback. +function findVsThread(api: CowritingApi, threadId: string): vscode.CommentThread { + const rendered = api.threadController.getRendered(DOC_REL); + assert.ok(rendered.some((r) => r.id === threadId), "thread is rendered"); + return ( + api.threadController as unknown as { + docs: Map }>; + } + ).docs + .get(DOC_REL)! + .vsThreads.get(threadId)!; +} +function setStatusViaApi(api: CowritingApi, threadId: string, status: "open" | "resolved"): void { + const vsThread = findVsThread(api, threadId); + void vscode.commands.executeCommand( + status === "resolved" ? "cowriting.resolveThread" : "cowriting.reopenThread", + vsThread, + ); +} + +suite("F2 region-anchored threads (host E2E)", () => { + test("create on selection persists a thread sidecar", async () => { + const doc = await openSample(); + const api = await getApi(); + const start = doc.getText().indexOf("quick brown fox"); + const editor = vscode.window.activeTextEditor!; + editor.selection = new vscode.Selection(doc.positionAt(start), doc.positionAt(start + "quick brown fox".length)); + + const threadId = await api.threadController.createThreadOnSelection("First note"); + assert.ok(threadId, "createThreadOnSelection returns an id"); + assert.ok(fs.existsSync(sidecarPath()), "sidecar written to disk"); + + const art = readSidecar(); + assert.strictEqual(art.threads.length, 1); + assert.strictEqual(art.threads[0].status, "open"); + assert.strictEqual(art.threads[0].messages[0].body, "First note"); + const anchorId = art.threads[0].anchorId; + assert.strictEqual(art.anchors[anchorId].fingerprint.text, "quick brown fox"); + + const rendered = api.threadController.getRendered(DOC_REL); + assert.strictEqual(rendered.length, 1); + assert.strictEqual(rendered[0].orphaned, false); + }); + + test("reply appends and resolve flips status, both persisted", async () => { + const api = await getApi(); + const art0 = readSidecar(); + const threadId = art0.threads[0].id; + + api.threadController.reply({ thread: findVsThread(api, threadId), text: "A reply" } as vscode.CommentReply); + const art1 = readSidecar(); + assert.deepStrictEqual( + art1.threads[0].messages.map((m) => m.body), + ["First note", "A reply"], + ); + + setStatusViaApi(api, threadId, "resolved"); + const art2 = readSidecar(); + assert.strictEqual(art2.threads[0].status, "resolved"); + }); + + test("reload renders the persisted thread at its anchor", async () => { + const doc = await openSample(); + const api = await getApi(); + api.threadController.renderAll(doc); + const rendered = api.threadController.getRendered(DOC_REL); + assert.strictEqual(rendered.length, 1); + assert.strictEqual(rendered[0].orphaned, false); + const anchorLine = doc.positionAt(doc.getText().indexOf("quick brown fox")).line; + assert.strictEqual(rendered[0].range.startLine, anchorLine); + }); + + test("external edit that moves the text re-anchors; deleting it orphans (INV-1)", async () => { + const uri = vscode.Uri.file(path.join(WS, DOC_REL)); + const original = fs.readFileSync(uri.fsPath, "utf8"); + + // Move the anchored text down by prepending lines (external change). + let doc = await externalWriteAndReload(uri, "PREPENDED LINE ONE\nPREPENDED LINE TWO\n" + original); + const api = await getApi(); + api.threadController.renderAll(doc); + let rendered = api.threadController.getRendered(DOC_REL); + assert.strictEqual(rendered[0].orphaned, false, "still anchored after a move"); + const movedLine = doc.positionAt(doc.getText().indexOf("quick brown fox")).line; + assert.strictEqual(movedLine, 4, "fox moved from line 2 to line 4 after prepending two lines"); + assert.strictEqual(rendered[0].range.startLine, movedLine); + + // Now delete the anchored text entirely → must orphan, not move. + const deleted = + "PREPENDED LINE ONE\nThe slow grey cat\n" + + original.replace("The quick brown fox jumps over the lazy dog.", "The slow grey cat naps."); + doc = await externalWriteAndReload(uri, deleted); + assert.strictEqual(doc.getText().includes("quick brown fox"), false, "anchored text is gone from the buffer"); + api.threadController.renderAll(doc); + rendered = api.threadController.getRendered(DOC_REL); + assert.strictEqual(rendered[0].orphaned, true, "deleted anchor must orphan (INV-1)"); + }); +}); diff --git a/test/model.test.ts b/test/model.test.ts new file mode 100644 index 0000000..53d5805 --- /dev/null +++ b/test/model.test.ts @@ -0,0 +1,58 @@ +import { describe, it, expect } from "vitest"; +import { + SCHEMA_VERSION, + emptyArtifact, + serializeArtifact, + type Artifact, +} from "../src/model"; + +function sampleArtifact(): Artifact { + return { + schemaVersion: SCHEMA_VERSION, + document: { path: "docs/spec.md" }, + anchors: { + a2: { fingerprint: { text: "beta", before: "x", after: "y", lineHint: 5 } }, + a1: { fingerprint: { text: "alpha", before: "", after: "", lineHint: 1 } }, + }, + threads: [ + { + id: "t1", + anchorId: "a1", + status: "open", + messages: [ + { id: "m1", author: { kind: "human", id: "ben" }, body: "hi", createdAt: "2026-06-10T00:00:00.000Z" }, + ], + }, + ], + attributions: [], + proposals: [], + }; +} + +describe("serializeArtifact", () => { + it("round-trips through JSON.parse", () => { + const a = sampleArtifact(); + const parsed = JSON.parse(serializeArtifact(a)) as Artifact; + expect(parsed).toEqual(a); + }); + + it("emits stable, sorted anchor keys and a trailing newline regardless of insertion order", () => { + const a = sampleArtifact(); + const out = serializeArtifact(a); + expect(out.endsWith("\n")).toBe(true); + // a1 must serialize before a2 even though a2 was inserted first + expect(out.indexOf('"a1"')).toBeLessThan(out.indexOf('"a2"')); + // byte-identical on re-serialize + expect(serializeArtifact(JSON.parse(out))).toBe(out); + }); + + it("emptyArtifact has the shared F3/F4 extension points present and empty", () => { + const e = emptyArtifact("docs/x.md"); + expect(e.schemaVersion).toBe(SCHEMA_VERSION); + expect(e.document.path).toBe("docs/x.md"); + expect(e.anchors).toEqual({}); + expect(e.threads).toEqual([]); + expect(e.attributions).toEqual([]); + expect(e.proposals).toEqual([]); + }); +}); diff --git a/test/reanchor.test.ts b/test/reanchor.test.ts new file mode 100644 index 0000000..5aabb31 --- /dev/null +++ b/test/reanchor.test.ts @@ -0,0 +1,28 @@ +import { describe, it, expect } from "vitest"; +import { buildFingerprint, resolve } from "../src/anchorer"; + +const ORIGINAL = "intro\nThe quick brown fox\noutro\n"; + +describe("re-anchor scenarios (SLICE-4 decision logic)", () => { + it("reload, document unchanged: resolves to the same range", () => { + const start = ORIGINAL.indexOf("quick brown"); + const fp = buildFingerprint(ORIGINAL, { start, end: start + "quick brown".length }); + expect(resolve(ORIGINAL, fp)).toEqual({ start, end: start + "quick brown".length }); + }); + + it("external edit that moves the anchored text down: re-resolves to the new range", () => { + const start = ORIGINAL.indexOf("quick brown"); + const fp = buildFingerprint(ORIGINAL, { start, end: start + "quick brown".length }); + const edited = "a new first line\nanother line\n" + ORIGINAL; + const newStart = edited.indexOf("quick brown"); + expect(newStart).not.toBe(start); + expect(resolve(edited, fp)).toEqual({ start: newStart, end: newStart + "quick brown".length }); + }); + + it("external edit that deletes the anchored text: orphaned, never moved (INV-1)", () => { + const start = ORIGINAL.indexOf("quick brown"); + const fp = buildFingerprint(ORIGINAL, { start, end: start + "quick brown".length }); + const edited = "intro\nThe lazy dog\noutro\n"; + expect(resolve(edited, fp)).toBe("orphaned"); + }); +}); diff --git a/test/store.test.ts b/test/store.test.ts new file mode 100644 index 0000000..20eb736 --- /dev/null +++ b/test/store.test.ts @@ -0,0 +1,57 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { CoauthorStore } from "../src/store"; +import { emptyArtifact, newId, serializeArtifact, type Artifact } from "../src/model"; + +let root: string; + +beforeEach(() => { + root = fs.mkdtempSync(path.join(os.tmpdir(), "coauthor-store-")); +}); +afterEach(() => { + fs.rmSync(root, { recursive: true, force: true }); +}); + +function withThread(): Artifact { + const a = emptyArtifact("docs/spec.md"); + a.anchors["a1"] = { fingerprint: { text: "hello", before: "", after: "", lineHint: 0 } }; + a.threads.push({ + id: "t1", + anchorId: "a1", + status: "open", + messages: [{ id: newId("m"), author: { kind: "human", id: "ben" }, body: "note", createdAt: "2026-06-10T00:00:00.000Z" }], + }); + return a; +} + +describe("CoauthorStore", () => { + it("returns null for a document with no sidecar", () => { + const store = new CoauthorStore(root); + expect(store.load("docs/spec.md")).toBeNull(); + }); + + it("computes the sidecar path as .threads/.json", () => { + const store = new CoauthorStore(root); + expect(store.sidecarPath("docs/spec.md")).toBe(path.join(root, ".threads", "docs", "spec.md.json")); + }); + + it("save then load round-trips the artifact", () => { + const store = new CoauthorStore(root); + const a = withThread(); + store.save("docs/spec.md", a); + expect(store.load("docs/spec.md")).toEqual(a); + }); + + it("writes byte-stable, pretty JSON (re-save is identical)", () => { + const store = new CoauthorStore(root); + const a = withThread(); + store.save("docs/spec.md", a); + const first = fs.readFileSync(store.sidecarPath("docs/spec.md"), "utf8"); + store.save("docs/spec.md", store.load("docs/spec.md")!); + const second = fs.readFileSync(store.sidecarPath("docs/spec.md"), "utf8"); + expect(second).toBe(first); + expect(first).toBe(serializeArtifact(a)); + }); +}); diff --git a/test/threadModel.test.ts b/test/threadModel.test.ts new file mode 100644 index 0000000..ab6dd4d --- /dev/null +++ b/test/threadModel.test.ts @@ -0,0 +1,49 @@ +import { describe, it, expect } from "vitest"; +import { emptyArtifact, type Provenance } from "../src/model"; +import { addThread, appendMessage, setStatus } from "../src/threadModel"; + +const BEN: Provenance = { kind: "human", id: "ben" }; +const fp = { text: "anchored", before: "a", after: "b", lineHint: 3 }; + +describe("addThread", () => { + it("adds an anchor + an open thread with the first message and returns their ids", () => { + const a = emptyArtifact("docs/x.md"); + const { threadId, anchorId } = addThread(a, fp, { author: BEN, body: "first" }); + expect(Object.keys(a.anchors)).toContain(anchorId); + expect(a.anchors[anchorId].fingerprint).toEqual(fp); + const t = a.threads.find((x) => x.id === threadId)!; + expect(t.anchorId).toBe(anchorId); + expect(t.status).toBe("open"); + expect(t.messages).toHaveLength(1); + expect(t.messages[0].body).toBe("first"); + expect(t.messages[0].author).toEqual(BEN); + expect(t.messages[0].id).toMatch(/^m_/); + expect(t.messages[0].createdAt).toMatch(/^\d{4}-\d{2}-\d{2}T/); + }); +}); + +describe("appendMessage", () => { + it("appends a message to the named thread", () => { + const a = emptyArtifact("docs/x.md"); + const { threadId } = addThread(a, fp, { author: BEN, body: "first" }); + appendMessage(a, threadId, { author: BEN, body: "reply" }); + const t = a.threads.find((x) => x.id === threadId)!; + expect(t.messages.map((m) => m.body)).toEqual(["first", "reply"]); + }); + + it("throws for an unknown thread id", () => { + const a = emptyArtifact("docs/x.md"); + expect(() => appendMessage(a, "nope", { author: BEN, body: "x" })).toThrow(/nope/); + }); +}); + +describe("setStatus", () => { + it("flips a thread between open and resolved", () => { + const a = emptyArtifact("docs/x.md"); + const { threadId } = addThread(a, fp, { author: BEN, body: "first" }); + setStatus(a, threadId, "resolved"); + expect(a.threads[0].status).toBe("resolved"); + setStatus(a, threadId, "open"); + expect(a.threads[0].status).toBe("open"); + }); +}); diff --git a/tsconfig.e2e.json b/tsconfig.e2e.json new file mode 100644 index 0000000..e50af7a --- /dev/null +++ b/tsconfig.e2e.json @@ -0,0 +1,13 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "module": "CommonJS", + "moduleResolution": "Node", + "noEmit": false, + "outDir": "out", + "rootDir": ".", + "sourceMap": true, + "types": ["node", "mocha", "vscode"] + }, + "include": ["src", "test/e2e"] +} diff --git a/vitest.config.ts b/vitest.config.ts index ed8bf77..1a98d14 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -4,5 +4,6 @@ export default defineConfig({ test: { environment: "node", include: ["test/**/*.test.ts"], + exclude: ["test/e2e/**", "node_modules/**"], }, });