diff --git a/AGENTS.md b/AGENTS.md index e44b734..e681b32 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,6 +6,19 @@ Run `partiful --help` and `partiful --help` for command reference. Run ## Things you will get wrong without reading this +### `myRsvp: SENT` means an inbound invite awaiting the user's reply +`events list` returns the authenticated user's raw Partiful RSVP state in `myRsvp`: + +| `myRsvp` | Meaning | Category | +|---|---|---| +| `GOING` | User accepted the invitation | Going | +| `MAYBE` | User replied maybe | Maybe | +| `INTERESTED` | User marked interest | Interested | +| `DECLINED` | User declined the invitation | Declined | +| `SENT` | Invitation sent to the authenticated user; no RSVP reply yet | Awaiting RSVP | + +`SENT` does not mean you sent an invitation. It is an inbound invitation awaiting your RSVP. When categorizing event lists, `isHost === true` takes precedence and means **Hosting**; otherwise use the `myRsvp` mapping. A `null` value means no personal guest RSVP record is present. Run `partiful schema events.list` for the machine-readable contract. + ### Contacts don't expose emails or phone numbers `contacts list` returns names, user IDs, and shared event counts. It does **not** return email addresses or phone numbers. This is a Partiful privacy constraint. Don't try to extract contact details — they aren't available through any endpoint. @@ -42,7 +55,7 @@ Integration tests (`tests/*.integration.test.js`) hit real Partiful APIs and nee ## Code conventions -- Plain JavaScript, no TypeScript, no build step +- TypeScript strict mode; executed through the tsx loader (no checked-in build output) - Commander.js CLI framework, Vitest for tests - One file per command group in `src/commands/` - Structured error objects: `{ status, error: { code, type, message } }` diff --git a/README.md b/README.md index 52d75bf..5dd1f38 100644 --- a/README.md +++ b/README.md @@ -235,8 +235,25 @@ All commands support `--format json`. Responses follow a consistent envelope: } ``` -- **`myRsvp`** — your personal RSVP: `GOING`, `MAYBE`, `DECLINED`, `SENT` (invited, no reply yet), or `null` on events you host. Filter on this to sync only the events you've accepted, e.g. `partiful events list | jq '.data[] | select(.myRsvp == "GOING")'`. -- **`isHost`** — `true` for events you own. (`going`/`maybe` are aggregate counts across all guests, not your status.) +`myRsvp` is the raw Partiful state for the authenticated user: + +| `myRsvp` | Meaning | Category | +|---|---|---| +| `GOING` | User accepted the invitation | Going | +| `MAYBE` | User replied maybe | Maybe | +| `INTERESTED` | User marked interest | Interested | +| `DECLINED` | User declined the invitation | Declined | +| `SENT` | Invitation sent to the authenticated user; no RSVP reply yet | Awaiting RSVP | + +**Important:** `SENT` does not mean you sent an invitation. It is an inbound invitation awaiting your RSVP. For categorization, `isHost === true` takes precedence and means **Hosting**; otherwise use the `myRsvp` mapping above. A `null` `myRsvp` means no personal guest RSVP record is present, commonly because you host the event. + +Filter on the raw field to sync only events you've accepted: + +```bash +partiful events list | jq '.data[] | select(.myRsvp == "GOING")' +``` + +`going` and `maybe` are aggregate counts across all guests, not your personal status. Run `partiful schema events.list` for the same mapping as machine-readable JSON. Errors return `{ "status": "error", "error": { "code": 1, "type": "api_error", "message": "..." } }`. diff --git a/skills/partiful-events/SKILL.md b/skills/partiful-events/SKILL.md index 4d59344..c6b30d4 100644 --- a/skills/partiful-events/SKILL.md +++ b/skills/partiful-events/SKILL.md @@ -14,13 +14,24 @@ partiful events list --past partiful events list --past --include-cancelled ``` -Each event in the list includes **your own** RSVP and host status: +Each event in the list includes **your own** raw RSVP state and host status: -| Field | Meaning | -|-------|---------| -| `myRsvp` | Your personal RSVP: `GOING`, `MAYBE`, `DECLINED`, `SENT` (invited, not yet answered), or `null` on events you host | -| `isHost` | `true` when you own the event | -| `going` / `maybe` | Aggregate guest counts (everyone), **not** your status | +| `myRsvp` | Meaning | Category | +|---|---|---| +| `GOING` | User accepted the invitation | Going | +| `MAYBE` | User replied maybe | Maybe | +| `INTERESTED` | User marked interest | Interested | +| `DECLINED` | User declined the invitation | Declined | +| `SENT` | Invitation sent to the authenticated user; no RSVP reply yet | Awaiting RSVP | + +**Do not misread `SENT`:** `SENT` does not mean you sent an invitation. It is an inbound invitation awaiting your RSVP. + +Categorize in this order: +1. `isHost === true` → **Hosting** (takes precedence over `myRsvp`) +2. Otherwise, use the `myRsvp` category in the table +3. `myRsvp === null` → no personal guest RSVP record is present + +`going` and `maybe` are aggregate counts across all guests, **not** your status. Use `partiful schema events.list` for this mapping as machine-readable JSON. ```bash # Only the events you've said yes to (e.g. to sync to a calendar) diff --git a/src/commands/schema.ts b/src/commands/schema.ts index e190088..84cbe0d 100644 --- a/src/commands/schema.ts +++ b/src/commands/schema.ts @@ -11,10 +11,32 @@ interface SchemaParameter { positional?: boolean; } -/** A command schema entry: invocation string + parameter map. */ +/** Meaning and user-facing category for one enum value. */ +interface SchemaEnumValue { + meaning: string; + category: string; +} + +/** A field in a command's machine-readable output contract. */ +interface SchemaOutputField { + type: string; + description: string; + values?: Record; + warning?: string; +} + +/** Machine-readable output and categorization guidance for agents. */ +interface SchemaOutput { + type: string; + fields: Record; + categorization?: Record; +} + +/** A command schema entry: invocation string, parameters, and optional output contract. */ interface CommandSchema { command: string; parameters: Record; + output?: SchemaOutput; } const SCHEMAS: Record = { @@ -24,6 +46,43 @@ const SCHEMAS: Record = { '--past': { type: 'boolean', required: false, description: 'Show past events' }, '--include-cancelled': { type: 'boolean', required: false, description: 'Include cancelled events' }, }, + output: { + type: 'EventSummary[]', + fields: { + myRsvp: { + type: '"GOING" | "MAYBE" | "INTERESTED" | "DECLINED" | "SENT" | null', + description: 'Authenticated user personal RSVP state; preserve this raw Partiful value', + values: { + GOING: { meaning: 'User accepted the invitation', category: 'Going' }, + MAYBE: { meaning: 'User replied maybe', category: 'Maybe' }, + INTERESTED: { meaning: 'User marked interest', category: 'Interested' }, + DECLINED: { meaning: 'User declined the invitation', category: 'Declined' }, + SENT: { + meaning: 'Invitation sent to the authenticated user; no RSVP reply yet', + category: 'Awaiting RSVP', + }, + null: { + meaning: 'No personal guest RSVP record is present', + category: 'No RSVP record', + }, + }, + warning: 'SENT is inbound invitation state; it does not mean the authenticated user sent an invitation', + }, + isHost: { + type: 'boolean', + description: 'True when the authenticated user owns the event; host categorization takes precedence over myRsvp', + }, + }, + categorization: { + precedence: 'isHost === true', + hosting: 'isHost === true → Hosting', + going: 'myRsvp === "GOING" → Going', + maybe: 'myRsvp === "MAYBE" → Maybe', + interested: 'myRsvp === "INTERESTED" → Interested', + declined: 'myRsvp === "DECLINED" → Declined', + awaitingRsvp: 'myRsvp === "SENT" → Awaiting RSVP', + }, + }, }, 'events.get': { command: 'events get ', diff --git a/tests/rsvp-semantics.test.js b/tests/rsvp-semantics.test.js new file mode 100644 index 0000000..56d697b --- /dev/null +++ b/tests/rsvp-semantics.test.js @@ -0,0 +1,66 @@ +import { readFileSync } from 'node:fs'; +import { describe, expect, it } from 'vitest'; +import { mapEventSummary } from '../src/lib/events.js'; +import { run } from './helpers.js'; + +const rootFile = (path) => readFileSync(new URL(`../${path}`, import.meta.url), 'utf8'); + +const expectedStatuses = { + GOING: { meaning: 'User accepted the invitation', category: 'Going' }, + MAYBE: { meaning: 'User replied maybe', category: 'Maybe' }, + INTERESTED: { meaning: 'User marked interest', category: 'Interested' }, + DECLINED: { meaning: 'User declined the invitation', category: 'Declined' }, + SENT: { + meaning: 'Invitation sent to the authenticated user; no RSVP reply yet', + category: 'Awaiting RSVP', + }, + null: { meaning: 'No personal guest RSVP record is present', category: 'No RSVP record' }, +}; + +describe('events.list RSVP semantics contract', () => { + it('publishes every myRsvp enum meaning and category in command schema output', () => { + const schema = run(['schema', 'events.list']).data; + + expect(schema.output.fields.myRsvp.values).toEqual(expectedStatuses); + }); + + it('states that SENT is inbound and host status takes categorization precedence', () => { + const schema = run(['schema', 'events.list']).data; + + expect(schema.output.fields.myRsvp.warning).toMatch( + /does not mean the authenticated user sent/i, + ); + expect(schema.output.categorization.precedence).toBe('isHost === true'); + expect(schema.output.categorization.hosting).toMatch(/isHost.*true/i); + expect(schema.output.categorization.awaitingRsvp).toMatch(/myRsvp.*SENT/i); + }); + + it('preserves SENT as the raw myRsvp value without adding a derived runtime field', () => { + const summary = mapEventSummary( + { + id: 'evt-sent', + guest: { status: 'SENT' }, + ownerIds: ['someone-else'], + guestStatusCounts: {}, + }, + 'me', + ); + + expect(summary.myRsvp).toBe('SENT'); + expect(summary).not.toHaveProperty('myRsvpCategory'); + expect(summary).not.toHaveProperty('myRsvpLabel'); + }); + + it.each([ + ['AGENTS.md', /SENT.*invitation sent to (?:the )?authenticated user.*awaiting.*RSVP/is], + ['README.md', /SENT.*invitation sent to (?:the )?authenticated user.*awaiting.*RSVP/is], + ['skills/partiful-events/SKILL.md', /SENT.*invitation sent to (?:the )?authenticated user.*awaiting.*RSVP/is], + ])('%s teaches the canonical SENT meaning', (path, sentPattern) => { + const contents = rootFile(path); + + expect(contents).toMatch(sentPattern); + expect(contents).toMatch(/INTERESTED/); + expect(contents).toMatch(/SENT.*does not mean.*you sent/is); + expect(contents).toMatch(/isHost.*true.*Hosting/is); + }); +});