Files
VolcanicSecretary/docs/superpowers/plans/2026-09-08-recurring-calendar-events.md

30 KiB
Raw Permalink Blame History

Recurring Google Calendar Events 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: Add create / modify / delete support for recurring events to the calendar tool in src/tools/calendar.ts, using raw RFC 5545 recurrence lines, instance-vs-series scope on edits, and a new list_instances action.

Architecture: All logic stays in src/tools/calendar.ts. The calendarParams TypeBox schema gains three optional fields (recurrence, scope, list_mode) and a new action list_instances. Update/delete resolve their target via a single GET on the event id when scope is not passed explicitly. Tests live in a new src/tools/calendar.test.ts with globalThis.fetch stubbed per call.

Tech Stack: TypeScript on Node 24, TypeBox for the tool schema, @earendil-works/pi-coding-agent for defineTool, Vitest for tests, Google Calendar REST API v3.


File Structure

  • Modify: src/tools/calendar.ts — schema, handler dispatch, formatters, new action.
  • Create: src/tools/calendar.test.ts — Vitest unit tests with a mocked fetch.
  • Modify: README.md — one new line in the manual E2E checklist.

getAccessToken is passed into handleCalendar as a function, so tests can just supply a stub that returns "test-token". fetch is called directly on the global, so tests stub globalThis.fetch and restore it in afterEach.


Task 1: Test harness + schema/type extensions

Files:

  • Create: src/tools/calendar.test.ts

  • Modify: src/tools/calendar.ts (schema, CalendarEvent interface, action dispatch stub)

  • Step 1: Create test file with fetch stub scaffolding

Create src/tools/calendar.test.ts:

import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { handleCalendar } from "./calendar.js";

type FetchCall = { url: string; init?: RequestInit };

function stubFetch(responses: Array<{ status?: number; body: unknown } | ((call: FetchCall) => { status?: number; body: unknown })>) {
  const calls: FetchCall[] = [];
  let i = 0;
  const fn = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
    const url = typeof input === "string" ? input : input.toString();
    const call = { url, init };
    calls.push(call);
    const entry = responses[i++];
    if (!entry) throw new Error(`unexpected fetch call: ${url}`);
    const resolved = typeof entry === "function" ? entry(call) : entry;
    const status = resolved.status ?? 200;
    return {
      ok: status >= 200 && status < 300,
      status,
      text: async () => JSON.stringify(resolved.body),
      json: async () => resolved.body,
    } as Response;
  });
  return { fn, calls };
}

const token = async () => "test-token";

describe("calendar tool", () => {
  let originalFetch: typeof globalThis.fetch;
  beforeEach(() => {
    originalFetch = globalThis.fetch;
  });
  afterEach(() => {
    globalThis.fetch = originalFetch;
  });

  it("placeholder — replaced by later tasks", () => {
    expect(true).toBe(true);
  });
});
  • Step 2: Run the placeholder test to confirm the harness runs

Run: npm test -- src/tools/calendar.test.ts Expected: PASS (1 test).

  • Step 3: Extend CalendarEvent and calendarParams in src/tools/calendar.ts

Modify src/tools/calendar.ts — extend the action union, add three optional params, and extend the CalendarEvent interface:

export const calendarParams = Type.Object({
  action: Type.Union(
    [
      Type.Literal("list"),
      Type.Literal("list_instances"),
      Type.Literal("create"),
      Type.Literal("update"),
      Type.Literal("delete"),
    ],
    { description: "list events / list instances of a series / create / update / delete an event" },
  ),
  calendar_id: Type.Optional(
    Type.String({ description: "Calendar id. Default 'primary' (the owner's main calendar)." }),
  ),
  time_min: Type.Optional(
    Type.String({
      description:
        "list/list_instances: ISO 8601 lower bound (inclusive), e.g. 2026-09-07T00:00:00+02:00. Default for list: now.",
    }),
  ),
  time_max: Type.Optional(
    Type.String({ description: "list/list_instances: ISO 8601 upper bound (exclusive). Optional." }),
  ),
  q: Type.Optional(Type.String({ description: "list: free-text search over summaries/descriptions." })),
  max_results: Type.Optional(
    Type.Number({ description: "list/list_instances: max events returned. Default 25, capped at 250." }),
  ),
  list_mode: Type.Optional(
    Type.Union([Type.Literal("occurrences"), Type.Literal("series")], {
      description:
        "list: 'occurrences' (default) expands recurring events into individual dates; 'series' returns only the master events with their RRULE.",
    }),
  ),
  event_id: Type.Optional(
    Type.String({
      description:
        "Event id. Required for update, delete, and list_instances. For update/delete on a recurring series, pass the master id (for scope=series) or an instance id from list_instances (for scope=instance).",
    }),
  ),
  scope: Type.Optional(
    Type.Union([Type.Literal("instance"), Type.Literal("series")], {
      description:
        "update/delete on recurring events: 'instance' targets one occurrence; 'series' targets the whole series. Default: auto-detected from the event id.",
    }),
  ),
  summary: Type.Optional(Type.String({ description: "create/update: event title." })),
  description: Type.Optional(Type.String({ description: "create/update: event description." })),
  location: Type.Optional(Type.String({ description: "create/update: location string." })),
  start: Type.Optional(
    Type.String({
      description:
        "create/update: start. ISO 8601 with offset for timed events (2026-09-10T10:00:00+02:00), or YYYY-MM-DD for all-day.",
    }),
  ),
  end: Type.Optional(
    Type.String({ description: "create/update: end. Same format as start." }),
  ),
  recurrence: Type.Optional(
    Type.Array(Type.String(), {
      description:
        "create/update series: RFC 5545 recurrence lines, e.g. ['RRULE:FREQ=WEEKLY;BYDAY=MO,WE']. Pass [] on update to strip recurrence and turn the series back into a single event.",
    }),
  ),
});

Also extend CalendarEvent:

interface CalendarEvent {
  id: string;
  summary?: string;
  description?: string;
  location?: string;
  start?: { dateTime?: string; date?: string; timeZone?: string };
  end?: { dateTime?: string; date?: string; timeZone?: string };
  htmlLink?: string;
  recurringEventId?: string;
  recurrence?: string[];
}

Add a list_instances case to handleCalendar's switch that throws for now (real impl arrives in Task 5):

    case "list_instances":
      throw new Error("list_instances not implemented yet");
  • Step 4: Typecheck compiles

Run: npm run typecheck Expected: PASS with no errors.

  • Step 5: Commit
git add src/tools/calendar.ts src/tools/calendar.test.ts
git commit -m "Extend calendar schema and types for recurring events"

Task 2: Create with recurrence + RRULE validation

Files:

  • Modify: src/tools/calendar.ts (createEvent, eventBody, add validateRecurrence, describeEvent to show RRULE)

  • Modify: src/tools/calendar.test.ts

  • Step 1: Write the failing tests

Replace the placeholder test with:

  it("create includes recurrence in the request body and shows RRULE in output", async () => {
    const { fn, calls } = stubFetch([
      {
        body: {
          id: "series-1",
          summary: "Standup",
          start: { dateTime: "2026-09-14T09:00:00+02:00", timeZone: "Europe/Warsaw" },
          end: { dateTime: "2026-09-14T09:15:00+02:00", timeZone: "Europe/Warsaw" },
          recurrence: ["RRULE:FREQ=WEEKLY;BYDAY=MO,WE"],
        },
      },
    ]);
    globalThis.fetch = fn as unknown as typeof globalThis.fetch;
    const out = await handleCalendar(
      {
        action: "create",
        summary: "Standup",
        start: "2026-09-14T09:00:00+02:00",
        end: "2026-09-14T09:15:00+02:00",
        recurrence: ["RRULE:FREQ=WEEKLY;BYDAY=MO,WE"],
      },
      "Europe/Warsaw",
      token,
    );
    expect(calls[0].url).toBe("https://www.googleapis.com/calendar/v3/calendars/primary/events");
    const body = JSON.parse(calls[0].init!.body as string);
    expect(body.recurrence).toEqual(["RRULE:FREQ=WEEKLY;BYDAY=MO,WE"]);
    expect(out).toContain("Created:");
    expect(out).toContain("RRULE=RRULE:FREQ=WEEKLY;BYDAY=MO,WE");
  });

  it("rejects recurrence entries missing an RFC 5545 prefix", async () => {
    globalThis.fetch = vi.fn() as unknown as typeof globalThis.fetch;
    await expect(
      handleCalendar(
        {
          action: "create",
          summary: "x",
          start: "2026-09-14T09:00:00+02:00",
          end: "2026-09-14T09:15:00+02:00",
          recurrence: ["FREQ=WEEKLY"],
        },
        "UTC",
        token,
      ),
    ).rejects.toThrow(/RFC 5545/);
  });
  • Step 2: Run the tests to confirm they fail

Run: npm test -- src/tools/calendar.test.ts Expected: FAIL — no recurrence in body, no RRULE in output, no validation error.

  • Step 3: Implement recurrence in eventBody + validation + describeEvent update

In src/tools/calendar.ts:

Add validation helper:

function validateRecurrence(recurrence: string[]): void {
  const allowed = /^(RRULE|EXDATE|RDATE|EXRULE):/;
  for (const line of recurrence) {
    if (!allowed.test(line)) {
      throw new Error(
        "recurrence entries must be RFC 5545 lines (RRULE:..., EXDATE:..., RDATE:..., EXRULE:...)",
      );
    }
  }
}

In eventBody, add:

  if (params.recurrence !== undefined) {
    validateRecurrence(params.recurrence);
    body.recurrence = params.recurrence;
  }

Update describeEvent to show the RRULE when present:

function describeEvent(event: CalendarEvent, timezone: string): string {
  const summary = event.summary ?? "(no title)";
  const when = describeWhen(event, timezone);
  const location = event.location ? ` @ ${event.location}` : "";
  const recurring = event.recurringEventId ? ` · recurring (series=${event.recurringEventId})` : "";
  const rrule = event.recurrence?.length ? ` · RRULE=${event.recurrence.join(" ")}` : "";
  return `${event.id} · ${summary} · ${when}${location}${recurring}${rrule}`;
}
  • Step 4: Run tests to confirm they pass

Run: npm test -- src/tools/calendar.test.ts Expected: PASS (2 tests + prior placeholder if still present — remove the placeholder if it is).

  • Step 5: Commit
git add src/tools/calendar.ts src/tools/calendar.test.ts
git commit -m "Support recurrence in create and RFC 5545 validation"

Task 3: list marks recurring occurrences + list_mode=series

Files:

  • Modify: src/tools/calendar.ts (listEvents)

  • Modify: src/tools/calendar.test.ts

  • Step 1: Write the failing tests

Append to describe("calendar tool", ...):

  it("list marks occurrences that belong to a series", async () => {
    const { fn } = stubFetch([
      {
        body: {
          items: [
            {
              id: "occ-1",
              summary: "Standup",
              start: { dateTime: "2026-09-14T09:00:00+02:00" },
              end: { dateTime: "2026-09-14T09:15:00+02:00" },
              recurringEventId: "series-1",
            },
          ],
        },
      },
    ]);
    globalThis.fetch = fn as unknown as typeof globalThis.fetch;
    const out = await handleCalendar({ action: "list" }, "Europe/Warsaw", token);
    expect(out).toContain("recurring (series=series-1)");
  });

  it("list list_mode=series requests master events only and shows the RRULE", async () => {
    const { fn, calls } = stubFetch([
      {
        body: {
          items: [
            {
              id: "series-1",
              summary: "Standup",
              start: { dateTime: "2026-09-14T09:00:00+02:00" },
              end: { dateTime: "2026-09-14T09:15:00+02:00" },
              recurrence: ["RRULE:FREQ=WEEKLY;BYDAY=MO,WE"],
            },
          ],
        },
      },
    ]);
    globalThis.fetch = fn as unknown as typeof globalThis.fetch;
    const out = await handleCalendar({ action: "list", list_mode: "series" }, "Europe/Warsaw", token);
    const url = new URL(calls[0].url);
    expect(url.searchParams.get("singleEvents")).toBe("false");
    expect(out).toContain("RRULE=RRULE:FREQ=WEEKLY;BYDAY=MO,WE");
  });
  • Step 2: Run tests to confirm they fail

Run: npm test -- src/tools/calendar.test.ts Expected: FAIL — list_mode=series currently forces singleEvents=true, and the recurring-suffix expectation already passes if Task 2's describeEvent change landed (skip this failure if applicable — the test still exercises the wiring).

  • Step 3: Update listEvents to honour list_mode

Replace the two lines that always set singleEvents=true / orderBy=startTime:

  const mode = params.list_mode ?? "occurrences";
  if (mode === "series") {
    url.searchParams.set("singleEvents", "false");
  } else {
    url.searchParams.set("singleEvents", "true");
    url.searchParams.set("orderBy", "startTime");
  }
  • Step 4: Run tests to confirm they pass

Run: npm test -- src/tools/calendar.test.ts Expected: PASS.

  • Step 5: Commit
git add src/tools/calendar.ts src/tools/calendar.test.ts
git commit -m "Add list_mode=series and recurring marker on list output"

Task 4: Scope resolution + update with instance/series semantics

Files:

  • Modify: src/tools/calendar.ts (updateEvent, new resolveScope helper)

  • Modify: src/tools/calendar.test.ts

  • Step 1: Write the failing tests

  it("update with default scope on an instance id PATCHes the instance", async () => {
    const { fn, calls } = stubFetch([
      { body: { id: "occ-1_20260916T070000Z", recurringEventId: "series-1", summary: "Standup" } }, // GET for scope
      {
        body: {
          id: "occ-1_20260916T070000Z",
          recurringEventId: "series-1",
          summary: "Standup",
          start: { dateTime: "2026-09-16T10:00:00+02:00" },
          end: { dateTime: "2026-09-16T10:30:00+02:00" },
        },
      }, // PATCH
    ]);
    globalThis.fetch = fn as unknown as typeof globalThis.fetch;
    const out = await handleCalendar(
      {
        action: "update",
        event_id: "occ-1_20260916T070000Z",
        start: "2026-09-16T10:00:00+02:00",
        end: "2026-09-16T10:30:00+02:00",
      },
      "Europe/Warsaw",
      token,
    );
    expect(calls[0].init?.method ?? "GET").toBe("GET");
    expect(calls[1].init?.method).toBe("PATCH");
    expect(calls[1].url).toContain("/events/occ-1_20260916T070000Z");
    expect(out).toContain("Updated:");
  });

  it("update with default scope on a master id PATCHes the master", async () => {
    const { fn, calls } = stubFetch([
      { body: { id: "series-1", recurrence: ["RRULE:FREQ=WEEKLY"], summary: "Old" } }, // GET
      {
        body: {
          id: "series-1",
          summary: "New",
          recurrence: ["RRULE:FREQ=WEEKLY"],
          start: { dateTime: "2026-09-14T09:00:00+02:00" },
          end: { dateTime: "2026-09-14T09:15:00+02:00" },
        },
      }, // PATCH
    ]);
    globalThis.fetch = fn as unknown as typeof globalThis.fetch;
    await handleCalendar(
      { action: "update", event_id: "series-1", summary: "New" },
      "Europe/Warsaw",
      token,
    );
    expect(calls[1].init?.method).toBe("PATCH");
    const body = JSON.parse(calls[1].init!.body as string);
    expect(body).toEqual({ summary: "New" });
  });

  it("update scope=instance rejects when the event has no recurringEventId", async () => {
    const { fn } = stubFetch([{ body: { id: "series-1", recurrence: ["RRULE:FREQ=WEEKLY"] } }]);
    globalThis.fetch = fn as unknown as typeof globalThis.fetch;
    await expect(
      handleCalendar(
        { action: "update", event_id: "series-1", scope: "instance", summary: "x" },
        "UTC",
        token,
      ),
    ).rejects.toThrow(/pass an instance id or use scope=series/);
  });

  it("update rejects recurrence when scope resolves to instance", async () => {
    const { fn } = stubFetch([{ body: { id: "occ-1", recurringEventId: "series-1" } }]);
    globalThis.fetch = fn as unknown as typeof globalThis.fetch;
    await expect(
      handleCalendar(
        { action: "update", event_id: "occ-1", recurrence: ["RRULE:FREQ=DAILY"] },
        "UTC",
        token,
      ),
    ).rejects.toThrow(/recurrence can only be set on the series master/);
  });

  it("update on a plain single event skips the classification GET", async () => {
    const { fn, calls } = stubFetch([
      {
        body: {
          id: "single-1",
          summary: "Renamed",
          start: { dateTime: "2026-09-14T09:00:00+02:00" },
          end: { dateTime: "2026-09-14T09:15:00+02:00" },
        },
      }, // PATCH (no prior GET — no scope passed, and classification only runs if scope defaulted)
    ]);
    // For a plain single event we still need one GET to know it's not recurring.
    // We keep behaviour: always GET when scope is not passed.
  });

Note: replace that last it block with an actual test:

  it("update PATCH body only carries fields the caller changed", async () => {
    const { fn, calls } = stubFetch([
      { body: { id: "series-1", recurrence: ["RRULE:FREQ=WEEKLY"] } },
      {
        body: {
          id: "series-1",
          summary: "s",
          recurrence: ["RRULE:FREQ=DAILY"],
          start: { dateTime: "2026-09-14T09:00:00+02:00" },
          end: { dateTime: "2026-09-14T09:15:00+02:00" },
        },
      },
    ]);
    globalThis.fetch = fn as unknown as typeof globalThis.fetch;
    await handleCalendar(
      { action: "update", event_id: "series-1", recurrence: ["RRULE:FREQ=DAILY"] },
      "UTC",
      token,
    );
    const body = JSON.parse(calls[1].init!.body as string);
    expect(body).toEqual({ recurrence: ["RRULE:FREQ=DAILY"] });
  });
  • Step 2: Run tests to confirm they fail

Run: npm test -- src/tools/calendar.test.ts Expected: FAIL — updateEvent currently PATCHes directly without a scoping GET or recurrence-vs-instance guard.

  • Step 3: Add resolveScope and rewrite updateEvent

In src/tools/calendar.ts:

type ResolvedScope = "instance" | "series" | "single";

async function classifyEvent(
  calId: string,
  eventId: string,
  getAccessToken: () => Promise<string>,
): Promise<CalendarEvent> {
  const resp = await fetch(
    `${CALENDAR_API}/calendars/${encodeURIComponent(calId)}/events/${encodeURIComponent(eventId)}`,
    { headers: { Authorization: `Bearer ${await getAccessToken()}` } },
  );
  await ensureOk(resp, "get event");
  return (await resp.json()) as CalendarEvent;
}

async function resolveScope(
  calId: string,
  params: CalendarParams,
  getAccessToken: () => Promise<string>,
): Promise<{ scope: ResolvedScope; event: CalendarEvent }> {
  if (!params.event_id) throw new Error("event_id required");
  const event = await classifyEvent(calId, params.event_id, getAccessToken);
  const detected: ResolvedScope = event.recurringEventId
    ? "instance"
    : event.recurrence?.length
    ? "series"
    : "single";
  if (!params.scope) return { scope: detected, event };
  if (params.scope === "instance" && detected !== "instance") {
    throw new Error("event_id is a series master; pass an instance id or use scope=series");
  }
  if (params.scope === "series" && detected === "instance") {
    throw new Error("event_id is an occurrence; pass the master id or use scope=instance");
  }
  return { scope: params.scope, event };
}

Replace updateEvent:

async function updateEvent(
  calId: string,
  params: CalendarParams,
  timezone: string,
  getAccessToken: () => Promise<string>,
): Promise<string> {
  if (!params.event_id) throw new Error("update requires event_id");
  const { scope } = await resolveScope(calId, params, getAccessToken);
  if (scope === "instance" && params.recurrence !== undefined) {
    throw new Error("recurrence can only be set on the series master (scope=series)");
  }
  const body = eventBody(params, timezone);
  if (Object.keys(body).length === 0) throw new Error("update requires at least one field to change");
  const resp = await fetch(
    `${CALENDAR_API}/calendars/${encodeURIComponent(calId)}/events/${encodeURIComponent(params.event_id)}`,
    {
      method: "PATCH",
      headers: {
        Authorization: `Bearer ${await getAccessToken()}`,
        "Content-Type": "application/json",
      },
      body: JSON.stringify(body),
    },
  );
  await ensureOk(resp, "update event");
  const event = (await resp.json()) as CalendarEvent;
  return `Updated: ${describeEvent(event, timezone)}`;
}
  • Step 4: Run tests to confirm they pass

Run: npm test -- src/tools/calendar.test.ts Expected: PASS.

  • Step 5: Commit
git add src/tools/calendar.ts src/tools/calendar.test.ts
git commit -m "Resolve update scope from event id and guard recurrence-on-instance"

Task 5: Delete with scope

Files:

  • Modify: src/tools/calendar.ts (deleteEvent)

  • Modify: src/tools/calendar.test.ts

  • Step 1: Write the failing tests

  it("delete on a series master returns the series message", async () => {
    const { fn, calls } = stubFetch([
      { body: { id: "series-1", recurrence: ["RRULE:FREQ=WEEKLY"] } }, // GET
      { status: 204, body: {} }, // DELETE
    ]);
    globalThis.fetch = fn as unknown as typeof globalThis.fetch;
    const out = await handleCalendar({ action: "delete", event_id: "series-1" }, "UTC", token);
    expect(calls[1].init?.method).toBe("DELETE");
    expect(out).toBe("Deleted series series-1 (all occurrences).");
  });

  it("delete on an occurrence cancels the instance and names the series", async () => {
    const { fn, calls } = stubFetch([
      { body: { id: "occ-1", recurringEventId: "series-1" } },
      { status: 204, body: {} },
    ]);
    globalThis.fetch = fn as unknown as typeof globalThis.fetch;
    const out = await handleCalendar({ action: "delete", event_id: "occ-1" }, "UTC", token);
    expect(calls[1].url).toContain("/events/occ-1");
    expect(out).toBe("Cancelled occurrence occ-1 of series series-1.");
  });

  it("delete on a plain single event keeps the original message", async () => {
    const { fn } = stubFetch([
      { body: { id: "single-1" } },
      { status: 204, body: {} },
    ]);
    globalThis.fetch = fn as unknown as typeof globalThis.fetch;
    const out = await handleCalendar({ action: "delete", event_id: "single-1" }, "UTC", token);
    expect(out).toBe("Deleted event single-1.");
  });
  • Step 2: Run tests to confirm they fail

Run: npm test -- src/tools/calendar.test.ts Expected: FAIL — current deleteEvent always returns Deleted event <id>. and skips the GET.

  • Step 3: Rewrite deleteEvent

Replace deleteEvent:

async function deleteEvent(
  calId: string,
  params: CalendarParams,
  getAccessToken: () => Promise<string>,
): Promise<string> {
  if (!params.event_id) throw new Error("delete requires event_id");
  const { scope, event } = await resolveScope(calId, params, getAccessToken);
  const resp = await fetch(
    `${CALENDAR_API}/calendars/${encodeURIComponent(calId)}/events/${encodeURIComponent(params.event_id)}`,
    {
      method: "DELETE",
      headers: { Authorization: `Bearer ${await getAccessToken()}` },
    },
  );
  await ensureOk(resp, "delete event");
  if (scope === "instance") {
    return `Cancelled occurrence ${params.event_id} of series ${event.recurringEventId}.`;
  }
  if (scope === "series") {
    return `Deleted series ${params.event_id} (all occurrences).`;
  }
  return `Deleted event ${params.event_id}.`;
}
  • Step 4: Run tests to confirm they pass

Run: npm test -- src/tools/calendar.test.ts Expected: PASS.

  • Step 5: Commit
git add src/tools/calendar.ts src/tools/calendar.test.ts
git commit -m "Scope delete to instance or series"

Task 6: list_instances action

Files:

  • Modify: src/tools/calendar.ts (new listInstances, wire it into the switch)

  • Modify: src/tools/calendar.test.ts

  • Step 1: Write the failing tests

  it("list_instances GETs the series' instances endpoint with the paging params", async () => {
    const { fn, calls } = stubFetch([
      {
        body: {
          items: [
            {
              id: "series-1_20260914T070000Z",
              summary: "Standup",
              start: { dateTime: "2026-09-14T09:00:00+02:00" },
              end: { dateTime: "2026-09-14T09:15:00+02:00" },
              recurringEventId: "series-1",
            },
          ],
        },
      },
    ]);
    globalThis.fetch = fn as unknown as typeof globalThis.fetch;
    const out = await handleCalendar(
      {
        action: "list_instances",
        event_id: "series-1",
        time_min: "2026-09-14T00:00:00+02:00",
        time_max: "2026-09-21T00:00:00+02:00",
        max_results: 5,
      },
      "Europe/Warsaw",
      token,
    );
    const url = new URL(calls[0].url);
    expect(url.pathname).toBe("/calendar/v3/calendars/primary/events/series-1/instances");
    expect(url.searchParams.get("timeMin")).toBe("2026-09-14T00:00:00+02:00");
    expect(url.searchParams.get("timeMax")).toBe("2026-09-21T00:00:00+02:00");
    expect(url.searchParams.get("maxResults")).toBe("5");
    expect(out).toContain("series-1_20260914T070000Z");
    expect(out).not.toContain("recurring (series=");
  });

  it("list_instances requires event_id", async () => {
    globalThis.fetch = vi.fn() as unknown as typeof globalThis.fetch;
    await expect(
      handleCalendar({ action: "list_instances" }, "UTC", token),
    ).rejects.toThrow(/list_instances requires event_id/);
  });
  • Step 2: Run tests to confirm they fail

Run: npm test -- src/tools/calendar.test.ts Expected: FAIL — the switch still throws "not implemented yet".

  • Step 3: Implement listInstances and add a suffix-suppressing describe helper

In src/tools/calendar.ts, add:

async function listInstances(
  calId: string,
  params: CalendarParams,
  timezone: string,
  getAccessToken: () => Promise<string>,
): Promise<string> {
  if (!params.event_id) throw new Error("list_instances requires event_id (the series master id)");
  const url = new URL(
    `${CALENDAR_API}/calendars/${encodeURIComponent(calId)}/events/${encodeURIComponent(params.event_id)}/instances`,
  );
  if (params.time_min) url.searchParams.set("timeMin", params.time_min);
  if (params.time_max) url.searchParams.set("timeMax", params.time_max);
  url.searchParams.set("maxResults", String(Math.min(params.max_results ?? 25, 250)));
  const resp = await fetch(url, {
    headers: { Authorization: `Bearer ${await getAccessToken()}` },
  });
  await ensureOk(resp, "list instances");
  const data = (await resp.json()) as { items?: CalendarEvent[] };
  const items = data.items ?? [];
  if (!items.length) return "No instances.";
  return items.map((event) => describeInstance(event, timezone)).join("\n");
}

function describeInstance(event: CalendarEvent, timezone: string): string {
  const summary = event.summary ?? "(no title)";
  const when = describeWhen(event, timezone);
  const location = event.location ? ` @ ${event.location}` : "";
  return `${event.id} · ${summary} · ${when}${location}`;
}

Update the switch:

    case "list_instances":
      return listInstances(calId, params, timezone, getAccessToken);
  • Step 4: Run tests to confirm they pass

Run: npm test -- src/tools/calendar.test.ts Expected: PASS.

  • Step 5: Commit
git add src/tools/calendar.ts src/tools/calendar.test.ts
git commit -m "Add list_instances action for recurring series"

Task 7: Final verification + README checklist entry

Files:

  • Modify: README.md

  • Step 1: Add a manual E2E checklist item

In README.md, append one item to the numbered list under ### Manual end-to-end checklist:

13. "Every Monday and Wednesday at 9 create a standup event for 15 minutes" → an event is created with `RRULE:FREQ=WEEKLY;BYDAY=MO,WE`; "list the next 3 standups" returns three occurrences; "move next Wednesday's standup to 10" changes just that instance; "delete the standup series" removes them all.
  • Step 2: Run the full test suite

Run: npm test Expected: PASS — all existing tests plus the new src/tools/calendar.test.ts suite.

  • Step 3: Run typecheck

Run: npm run typecheck Expected: PASS with no errors.

  • Step 4: Run build to catch anything typecheck missed

Run: npm run build Expected: exits 0, dist/ updated.

  • Step 5: Commit README + verify clean tree
git add README.md
git commit -m "Document recurring-event manual E2E check"
git status

Expected: git status shows a clean working tree.


Self-Review

Spec coverage

Spec section Task
Recurrence input shape (recurrence: string[]) Task 1 (schema) + Task 2 (create body + validation)
Scope resolution (scope param, auto-detect, error rules) Task 4 (resolveScope)
list occurrences marker Task 3
list_mode=series Task 3
list_instances action Task 1 (schema) + Task 6 (impl)
create with recurrence Task 2
update semantics (instance/series, reject recurrence on instance) Task 4
delete semantics (instance/series/single messages) Task 5
Output formats (recurring suffix, RRULE line, delete messages, instance line) Tasks 2, 3, 5, 6
Errors (missing event_id on list_instances, master-vs-instance mismatch, recurrence-on-instance, invalid RRULE prefix) Tasks 2, 4, 5, 6
Types (recurringEventId, recurrence on CalendarEvent) Task 1
Tests (all bullets from spec §Testing) Tasks 2–6
README E2E line Task 7

Every spec requirement maps to at least one task.

Placeholder scan: none — every step has full code, an exact command, and an expected result.

Type consistency: resolveScope returns { scope: ResolvedScope; event: CalendarEvent } and is called with those types in Tasks 4 and 5. describeEvent gains a recurring and rrule suffix in Task 2 and is used by list, create, update. describeInstance is used only by list_instances. CalendarEvent.recurringEventId and CalendarEvent.recurrence are added in Task 1 and consumed in Tasks 2–6. validateRecurrence is defined in Task 2 and used from eventBody. All names line up.