[SILO-1463] feat: add new v2 client for v2 apis - #56
Conversation
…operations
`client.v2` exposes every api_v2 operation through a single chained form rooted at
the workspace, mirroring the API's own scope tree:
const ws = client.v2.workspace("acme"); // zero-I/O locator
const proj = ws.project("ENG"); // key or UUID
await proj.workItems.create({ name: "Fix login bug", state: "Todo" });
await ws.workItems.retrieveByIdentifier("ENG-12");
await ws.wiki.pages.create({ name: "Runbook" }); // public page -> default collection
await client.v2.users.me(); // the six non-workspace operations
- Kernel: own axios instance with RFC 9457 errors and PlaneNetworkError,
offset/cursor envelopes with a stall guard, ?fields/?expand/?order_by validated
per operation against the golden, typed `Pick<T, F>` field projection, upsert,
bulk create/update/delete with per-row results, findByName, custom verb
actions, scope-bound resources (`new Resource(transport, scope)`).
- Spec-generated constants (`pnpm codegen:v2`) for all 406 operations; every
implemented operation is declared in exactly one resource's `operations` map
and a two-way coverage test enforces 406/406.
- Method set is identical to plane-sdk (Python) (camelCase vs snake_case).
- Root exports keep every v1 name; v2 types that collide are aliased `V2*`, and
`scripts/check-types-bundle.mjs` guards the public export surface on build.
- Unit tests under tests/unit/v2 (nock); e2e under tests/e2e/v2 skip without env.
- CI: unit tests gated; a secret-gated `v2-golden-drift` job regenerates the
constants against plane-ee's golden.
- Version 0.3.0. v1 surface untouched.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015XXZ9CT96T1dZoiSYmtiNe
|
Important Review skippedToo many files! This PR contains 341 files, which is 241 over the limit of 100. To get a review, reduce the PR to 100 files or fewer by splitting it into smaller PRs or changing its base branch. Upgrade to a paid plan to raise the limit. Usage-priced reviews support at most 300 files. ⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Advanced Run ID: ⛔ Files ignored due to path filters (2)
📒 Files selected for processing (341)
You can disable this status message by setting the Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
|
Linked to Plane Work Item(s) This comment was auto-generated by Plane |
…ookups (review feedback)
Reviewer feedback on the v2 surface (runs/sdk-v2-foundation/plans/2026-09-03-team-feedback.md, items 1, 2 and the SDK-now half of 5).
Renames (work item type properties, project + workspace scoped):
- WorkItemTypeProperties.attach -> link, .detach -> unlink
- WorkspaceWorkItemTypeProperties.attach -> link, .detach -> unlink
(operations map keys stay attach/detach: golden operationIds)
Removed every manage* method in favour of add(parentId, ids) / remove(parentId, ids)
bridge verbs that send one verb per call and resolve to the plain id array:
- Cycles.manageWorkItems -> cycles.workItems.add/remove (new CycleWorkItems)
- Modules.manageWorkItems -> modules.workItems.add/remove (new ModuleWorkItems)
- Milestones.manageWorkItems -> milestones.workItems.add/remove (new MilestoneWorkItems)
- Customers.manageWorkItems -> customers.workItems.add/remove (new CustomerWorkItems)
- Releases.manageWorkItems -> releases.workItems.add/remove (new ReleaseWorkItems)
- Releases.manageLabels -> releases.labels.add/remove (on the ReleaseLabels catalog)
- Initiatives.manageWorkItems -> initiatives.workItems.add/remove (new InitiativeWorkItems)
- Initiatives.manageProjects -> initiatives.projects.add/remove (new InitiativeProjects)
- Initiatives.manageLabels -> initiatives.labels.add/remove (on the InitiativeLabels catalog)
- CollectionMembers.manage -> collections.members.add(collectionId, CollectionMemberAddItem[]) / .remove(collectionId, userIds)
- CollectionPages.manage -> collections.pages.add/remove
Kernel: V2Resource.doBridge/doBridgeAt replace the hand-rolled transport.request copies;
1..BRIDGE_MAX_IDS (100) ids enforced client-side (RangeError/TypeError before any request).
The *ManageRequest/*ManageResponse model types are gone from the public surface (one
internal models/v2/Bridge.ts shape backs the kernel); the types-bundle export snapshot is
regenerated intentionally (1450 -> 1425 entries).
New server-side lookups via doFindOne:
- Roles.findBySlug(slug, { namespace? })
- EstimatePoints.findByKey(estimateId, key)
- WorkItemPropertyOptions.findByName / WorkspaceWorkItemPropertyOptions.findByName / WorkItemPropertyContexts.findByName (propertyId, name)
Cycles/Modules/Milestones moved from flat files into folders (index.ts + WorkItems.ts) per the
sub-resource convention; import paths are unchanged.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014QwQ1tqb3831E7rezg5zqs
|
Review feedback landed as one commit on top (3a244c0), so the delta is reviewable on its own. Public method tree stays identical to the Python SDK (513 = 513 after normalisation).
Checks on the new commit: |
◈ PR Lens
Architecture 10 components touched across 5 lanes. Inside the changed components — 2 viewsComponent view — v2 Engine and Transport The core request pipeline, validation, pagination, and error-decoding engine for v2 calls Component view — v2 Resource Tree and Row Navigation The hierarchical v2 resource structure and the loaded row engine that binds child methods Data flow
The other flows — 1 sequence
Drill down
|
Every method now passes its path ids per call rather than leaning on a scope a locator bound once. The kernel gains what that needs: - `extraPaths` + `urlFor(action, ids)`: a method whose URL is not built from the class `path` (a bridge that posts at its parent's URL, a report on a sibling route) declares its own template, keyed by method name, so `url_for` makes the same choice at call time that the sweeps make when they read it. The five hand-rolled template call sites move to `urlForTemplate`, which is the raw filler they were actually using. - `MissingPathIdError`: a template key with no value behind it named the key and nothing else, which is the first failure a caller hits on the flat shape. It now names the resource, the method, the template, the missing id and the ids that were supplied. An empty string counts as missing — it would otherwise build `/projects//states/`, a well-formed URL pointing somewhere else. (Python shipped a bare error here and had to repair it a wave later.) - singleton read/update helpers for routes whose row *is* the collection, a custom-action helper for the response envelopes that are not the resource's own row, and a void-action helper for the 204s — the four shapes that were being hand-rolled as `transport.request` blocks, one resource at a time. `scope` survives on the constructor, marked retired, only so the not-yet-migrated classes keep working mid-migration; the last task of the plan deletes it with the locators. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014QwQ1tqb3831E7rezg5zqs
A fetched row becomes the place its children live: its own fields, one navigation
property per child, and `$loaded` carrying the ids that produced it plus the
field names the server actually returned.
`Owned<TResource, TIds>` is the load-bearing type. Generic over the child
resource *and* over the tuple of ids being bound, it maps each method to the same
method minus exactly those leading parameters — so `project.states.list()` is
`Promise<Page<State>>`, `project.states.retrieve("s-1")` is `Promise<State>`, and
`project.states.lst()` does not exist. Python's first attempt returned `Any` here
and it was a Critical finding; in Node this is the whole value proposition, so
`tests/unit/v2/navigable-rows.test.ts` pins the resolved types with a
bidirectional `Exact<>` check (an `extends` assertion alone would pass for `any`)
and with `@ts-expect-error` on a misspelling, on one argument too many, and on a
grandchild that needs its own row's id.
One limit, documented where it bites: TypeScript erases type parameters when it
infers through a conditional type, so a *generic* overload cannot survive the
transform. A navigated `list({ fields: [...] })` answers the full row type rather
than the narrowed one; the flat call keeps the projection. Matching the overload
set instead is strictly worse — the inference erases `F` to its constraint and
claims presence for fields the projection dropped.
`LoadsNavigableRows` gives a resource `load`/`loadPage`/`loadIterate`. `iterate`
has its own helper because it is the one that gets forgotten: Python shipped
`list` navigable and `iterate` plain, so paging silently lost navigation.
Runtime `owned()` prepends ids positionally and refuses a definite parameter-name
mismatch first — every path id is a `string`, so the type system cannot notice a
resource whose leading parameters are ordered differently, and the wrong values
would flow into a well-formed wrong URL.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014QwQ1tqb3831E7rezg5zqs
…s, comments
The five resources every other one copies. Each takes the ids its URL template
names as leading positional parameters, in path order, named after the resource
they identify — singular, no `Id` suffix, the method's own primary key included:
`retrieve(slug, project, state)`, never `stateId`. Templates keep the golden's
`{project_id}` spelling.
`Projects` and `WorkItems` return navigable rows from every row-returning method
— `retrieve`, `create`, `update`, `upsert`, `findByName`, `list` and `iterate` —
and the loader is given the caller's `fields`, so presence reflects the response
rather than the request. `Projects.rowId` prefers the readable identifier, so a
fetched project's children hit `.../projects/ENG/states/`, not the UUID.
`Projects.summary` and `roleDistribution` stop hand-rolling their requests: one
goes through the custom-action helper on a row, the other through an `extraPaths`
override, and both are now validated against the golden like any other call.
The tree gains its flat root: `v2.projects`, with `states`/`labels`/`workItems`
under it and `comments` under those. A family joins the flat tree and leaves the
`workspace(slug).project(key)` locator chain in the same move, so neither shape is
ever a stale copy of the other — `locators.test.ts` pins both halves.
The six work-item sub-resources that have not migrated stay attached and stay off
the loaded row: an unmigrated child cannot be navigated to yet, and the opt-out
list is what makes that a scheduled migration rather than an omission.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014QwQ1tqb3831E7rezg5zqs
`tests/unit/v2/tree-walk.ts` derives the swept set as **every** resource class declared under `src/api/v2/`, minus an explicit opt-out list that may only shrink. The inversion is the point: Python's first attempt selected by heuristic (classes with a `list`, wired to the tree) and silently left 18 of its 90 classes outside every sweep, which is how the path-id rule came to be broken across 16 of them with nothing failing. TypeScript has no `pkgutil`, so the enumeration is triangulated from three derivations that must agree — the TypeScript AST's `class X extends V2Resource` declarations, the modules those files export, and the public barrel — and a fourth check requires the constructed tree to reach every one of them. A class exported but unreachable, or declared but unexported, fails. Proving that walk is what surfaced `GroupSync`'s config singleton sitting outside it: the walk skipped the attribute name `config` as plumbing, and 88 of 89 classes looked like all of them. It now skips plumbing by type. The four sweeps: - **path-id-naming** — every method opens with exactly the ids its own template names, in path order, camel-cased with no `Id` suffix; and no parameter names a path id under a suffixed spelling. A body field that merely ends in `Id` (`Cycles.transfer`'s destination) is outside the rule, which is proved on a template rather than trusted. It also carries the opt-out guards: the ratchet, the staleness check, and the rule that a class already flat-shaped cannot stay opted out. - **fields-coverage** / **expand-coverage** — a method whose own operation offers the option in the golden must make it reachable. Read off the *types*: in Node the params object reaches the kernel as one blob, so declaring the property is what makes it reachable and validated, and Python's second "is it threaded through?" check has nothing to check. - **loaded-navigation** — a loaded row's navigation properties must be exactly the migrated children its resource attaches; each must wrap its own child, hand back an owned view rather than the bare resource, and bind ids into leading parameters that actually carry them. This one bit immediately: owned views were exposing the kernel's `protected` hooks, which `protected` cannot prevent at runtime. Signatures come from the TypeScript AST, not from the emitted JavaScript: type erasure destroys parameter types, overloads and doc comments, and parameter *names* are the rule being enforced. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014QwQ1tqb3831E7rezg5zqs
Proving the path-id sweep bites is what found this: renaming a primary key on the *implementation* signature alone left the first overload — and so the hover text the rule is written about — untouched, and the sweep passed. An overload set can disagree with itself, so every declaration is now checked. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014QwQ1tqb3831E7rezg5zqs
…bite Proving the navigation sweep is what found this. Its "wraps its own child" check compared method names, and sibling resources routinely have identical method sets — `owned(this.states, …)` under `labels` builds a well-formed call to the wrong URL and passed. An owned view now carries its binding under a symbol (non-enumerable, outside the public type), and the sweep compares identity. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014QwQ1tqb3831E7rezg5zqs
Artifacts, AuditLogs, PermissionSchemes, Roles and both permission singletons take their path ids per call. Three Artifacts methods that hand-rolled `transport.request` now go through the kernel's custom-action helper, and both `permissions/me/` singletons through the singleton read rather than `collectionUrl` by hand. `Roles` carries over the collision Python found: the golden's own `?slug=` role filter collides with the leading path id, which is the workspace. It is exposed as `roleSlug` and mapped back onto the `slug` query key, so the filter stays reachable rather than being suppressed. Opt-out list: 84 -> 78. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014QwQ1tqb3831E7rezg5zqs
Stickies, Teamspaces, WorkspaceViews, ProjectViews, CustomerProperties, WorkItemRelationDefinitions and both work-item template resources take their path ids per call, and now expose `?fields=`/`?expand=` on create and update wherever the golden declares them — the coverage sweeps read that off the golden rather than off a list, so the omissions surfaced as soon as the classes left the opt-out. `ProjectWorkItemTemplates.use` moves from a hand-rolled request onto the kernel's custom-action helper; its response is a work item, not a template row. Opt-out list: 78 -> 70. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014QwQ1tqb3831E7rezg5zqs
The band the plain recipe does not cover on its own: - singletons with no primary key — both `Features` classes, both `Permissions` classes' siblings, the group-sync config and `Users.me` — now read and write through the kernel's singleton helpers; - alternate templates — `WorkspaceWorkItems.retrieveByIdentifier`, `WorkspaceMembers.remove` and `Invitations.bulk` move their hand-built URLs into `extraPaths`, so `urlFor` builds them and the shape sweep checks each method against the template it actually uses; - `WebhookLogs` carries its webhook id in the *collection* path, so it is a leading id on `list` as well as `retrieve`; - `GroupSyncProjectMappings` is workspace-level despite its name — its doc comment now says so, because the name invites the opposite guess. `Assets.create` and `UserAssets.create` deliberately stop offering `?fields=`: their presigned upload data exists only in that one reply, so a projection could drop it beyond recovery. Both are named in `ONE_TIME_RESPONSES` with the reason, which the fields sweep checks is repeated where a reader of the method will see it. Opt-out list: 70 -> 55. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014QwQ1tqb3831E7rezg5zqs
`ProjectWorklogs.summary` answers a bare JSON array rather than a `Page` envelope, so it moves onto the kernel's custom-action helper instead of a hand-rolled request, with the shape said plainly in its doc comment. Opt-out list: 55 -> 53. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014QwQ1tqb3831E7rezg5zqs
…s them All six — activities, attachments, links, worklogs, relations, dependencies — take `slug, project, workItem` per call, replacing the per-class private `pk()` helper that existed only to re-supply the ids a locator had bound. `Relations` and `Dependencies` answer one dict-shaped object rather than a `Page`, so their `list` reads through the kernel's singleton helper, and both `delete`s are keyed by the *related* work item: the API mints no id for a relation row, so the pair identifies which one to remove. Migrating them makes `LoadedWorkItem` owe a navigation property per child, which the navigation sweep demands the moment a child leaves the opt-out — so a fetched work item now reaches all seven of its children, not just comments. `Attachments.create` joins the two asset creates in `ONE_TIME_RESPONSES`: its presigned upload data is not re-fetchable. Opt-out list: 53 -> 47. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014QwQ1tqb3831E7rezg5zqs
…ld is flat The locator doc comments and `locators.test.ts` both claimed a family leaves the locator the moment it goes flat. That was true for `states`, `labels` and `workItems`, which had `Projects` waiting for them; it is not true of the workspace band, which is migrated but has no flat attachment point until the tree wiring. Rather than leave a rule the code visibly breaks, both now say the real one — the locator is a holding pen, and what it holds already takes its ids per call — with a test that proves it by passing a slug that differs from the bound one. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014QwQ1tqb3831E7rezg5zqs
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014QwQ1tqb3831E7rezg5zqs
Two holes in the enforcement sweeps, both of the same shape: a class can be outside a rule because of how the swept set is derived, not because anybody decided it should be. `loaded-navigation.test.ts` put every navigation assertion inside `describe.each(NAVIGABLE)`, and `NAVIGABLE` is chosen by `prototype instanceof LoadsNavigableRows`. A family that migrates, attaches its migrated children and stays on plain `V2Resource` was therefore checked by zero navigation assertions while every URL test passed — the hole the Python SDK carried for four plans, found only when `workspaces.retrieve()` turned out to answer a bare row whose 24 children were unreachable. Attaching a migrated child now obliges a resource to be navigable. `path-id-naming.test.ts` compared entries against the constructed tree in one direction only, so a class extending a shared derived base — invisible to `isResourceHeritage`, which matches the two kernel base identifiers literally — was outside all four sweeps and outside the barrel check too. The comparison is now symmetric: a class the tree reaches that no entry declares fails. Proved by introducing each violation and confirming the sweep names it: a migrated `States` attaching `labels` while extending `V2Resource`, and a `StatesProbe extends States` wired at `v2.projects.probe`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014QwQ1tqb3831E7rezg5zqs
…lden `methodsOffering` looks a method's action up in its class's `operations` map and `continue`s when it finds nothing, so a method with no entry is checked for neither `fields` nor `expand` — silently, and with nothing anywhere recording that it was skipped. The existing coverage net could not see it: it asks only whether each golden operationId is declared somewhere, never whether a given method name has a key. The gap is worst where the migration is least regular. A CRUD five-pack is spelt the same way everywhere; `retrieveByIdentifier`, `summary`, `roleDistribution` and the membership bridges each need a key written by hand, and those are the ones that would have escaped both option sweeps. `operationActionFor` resolves the key a method actually spends — `iterate` pages `list`, and every `findBy*` is a filtered `list` through `doFindOne`. It is kept separate from `ACTION_ALIASES` on purpose: a `findBy*` takes the value to match and no params object, so folding it into the option sweeps would demand a `fields` the wrapper has nowhere to put. Proved by renaming `Projects`' `summary` key to `summaryTypo`: the new assertion named `Projects#Projects.summary()` while the golden->declared check stayed green, which is exactly the divergence it exists to close. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014QwQ1tqb3831E7rezg5zqs
…undles
Two kernel defects in the loaded-row machinery, both of which fire in ordinary
downstream use rather than in the tests.
`loadRow` defined navigation properties with `defineProperty`, which overwrites
the spread data property. A navigation property named after a real API field
therefore replaced that field silently while `$loaded.present` went on reporting
it present — verified by pointing `Projects`' navigation at `name` before the
fix: `typeof row.name` was `object` and `present.has("name")` was still `true`.
The collision is real, not hypothetical: Python needed aliases for
`Estimate.points` and `WorkItemProperty.options`, both families task 3 still has
to migrate. `loadRow` now refuses the collision where it happens, and the sweep
builds a row carrying every field the golden says the resource returns — `rowOf`
builds `{ id }` alone, so no assertion could ever have seen a collision.
`assertLeadingParameters` read parameter names out of `Function.toString`. Under
esbuild or terser those are mangled, so it saw `[a, b]` against `[slug,
project]`, called it a definite mismatch, and threw a `TypeError` on every
navigated call — hardest on entirely correct consumer code. It is now gated on a
canary whose own parameter names are mangled by the same pass, so it can tell
"wrong order" from "names are gone" and stands down for the latter. Nothing is
lost: the source-level sweep enforces the same rule against the TypeScript,
where names cannot be mangled, and is the authoritative check.
Proved by deleting the gate line and watching the new regression test throw the
old TypeError, and by pointing a navigation property at a real field and
watching the new sweep name it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014QwQ1tqb3831E7rezg5zqs
`iterate` took the same `ListXParams` as `list`, `fields` and all, and declared `AsyncGenerator<FullRow>` — so a caller who asked for two fields got a type promising every field the server had just been told to omit. That is the same unsoundness used to reject matching the overload set in `Owned<…>`, sitting in the exemplars and copied into every class that followed them. `iterate` now mirrors `list`'s overload set across all 32 migrated classes that declare both, narrowing to `Pick<Row, F | "id">` when `fields` is a literal tuple and answering the full row otherwise. The rule is swept rather than left to the 32 edits: `list` and `iterate` are the same operation — `ACTION_ALIASES` already maps one onto the other — so `iterate` must narrow exactly where `list` does. That needs no exception list, because it never asks a class to narrow, only to be consistent with itself, and it holds for the classes task 3 has yet to migrate without anybody remembering it. Proved twice: the compile-time test now needs its `@ts-expect-error` on an iterated row's unrequested field (before the change TypeScript reported the directive as unused, which is the unsoundness stated as a compiler error), and reverting `Labels.iterate` to its single declaration made the sweep name `Labels#Labels`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014QwQ1tqb3831E7rezg5zqs
… unlocked `generated/constants.ts` was built from a plane-ee checkout sitting on an unrelated branch at 406 operations, so Node's generated types silently lacked capabilities the API had shipped on 2026-09-04. Regenerated from the detached `origin/preview` checkout at 407: `workspaces_retrieve` and its `FIELDS` entry are now present, and the header records the golden it actually came from. The refresh also brought the `display_name` query filter, which `origin/preview` declares on all three property list operations and the stale copy declared on none. Task 2 left `find_by_display_name` off customer properties for exactly that reason, so it lands here with the filter on `ListCustomerPropertiesParams`, mirroring Python's `plane/api/v2/customer_properties.py`. `display_name` is the label a person sees on screen; `name` is the slug the server derives from it, so this is the lookup a caller reaching for a property they can see actually wants. The one thing the refresh flagged that is not fixed here is `workspaces_retrieve` itself: implementing it means introducing the `v2.workspaces` root, which the variant-F plan schedules for the tree-wiring task, so guessing its shape now would pre-empt that design. It is recorded in `UNIMPLEMENTED_OPERATIONS` with its reason, guarded from both ends and ratcheted — an entry that is not a real golden id fails, an entry that turns out to be implemented fails as stale, and the list cannot grow. Proved by adding `states_list` and `not_a_real_operation` and watching both guards name the right one. The `expand` and `fields` sweeps flagged nothing new: the operations the refresh touched are the one nobody implements yet. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014QwQ1tqb3831E7rezg5zqs
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014QwQ1tqb3831E7rezg5zqs
…surface The support layer now goes through the shipped resources instead of the raw transport. `createProject`/`deleteProject`/`sweepLeftoverProjects` were written when there was no v2 projects resource to call; there is one now, so every suite's `beforeAll` doubles as a live assertion that `Projects.create`'s URL and body shape are right — the defect class the unit sweeps structurally cannot see. The sweep also pages through `projects.iterate`, which follows whichever envelope the server sends; the hand-rolled offset loop it replaces would have silently swept nothing against a cursor-paginating deployment. `V2Suite` now carries the suite project as a fetched, navigable row (`projectRow`) and a lazy `workspace()`, because a suite that holds only the two ids can only ever drive one of the two shapes the SDK ships. `support/specs.ts` grows both ways in — `flat(client, slug, project)` passing the path ids per call, `navigated(projectRow)` taking them off a fetched row — so the behavior suites can run the same assertions over both. The small suites are split deliberately rather than uniformly: artifacts, stickies, teamspaces, project features/members/permissions/worklogs and intakes are driven navigated; roles, invitations, assets, audit logs, permission schemes, webhook logs and workspace members stay flat. `roles` asserts the two agree on the same rows. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014QwQ1tqb3831E7rezg5zqs
`crud.e2e.test.ts` now runs its whole body twice, flat and navigated, over both
resources. That is the point of the file: the two shapes reach the same HTTP but
not through the same code, and only the navigated one goes through `owned()`.
A mis-ordered pair of prepended path ids is two strings in two string
parameters — the type checker cannot see it, the sweeps cannot see it, and the
URL it builds is well-formed and points at the wrong project. A live call is the
only thing that catches it.
The other `SPECS` suites each name one way in and say why, chosen so that every
method on `ResourceOps` is exercised navigated somewhere: `bulk` (the three
`bulk*`), `pagination` (`iterate`, a generator method wrapped like any other),
`find-by-name` (`findByName`, which loses two of its three leading parameters).
`upsert` and `errors` stay flat — neither is sensitive to the way in.
The typed-projection block moves to the flat form because it has to: `Owned`
erases the type parameter through its conditional, so a navigated
`list({ fields })` answers the full row. That is the documented trade-off, and
it now has a live assertion of its own — the projection still happens on the
wire, only the static type widens.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014QwQ1tqb3831E7rezg5zqs
…r chain Views and pages split along the seam that matters: the project half navigated off the fetched project row, the workspace half flat. `wiki` and `groupSync` stay flat by necessity, not preference — both are grouping nodes that consume no path id of their own, so neither can ever be a navigation property on a row and each child takes the slug itself. The comments say so at the call site. `projects.e2e.test.ts` stays flat throughout for the same structural reason: it exercises `Projects` itself, and the row a fetched project answers reaches its children, not the collection it came from. Two uuid-vs-identifier equivalence checks could not stay navigated and moved to the flat form with a note: a navigated row is already bound to whichever key `rowId` chose, so it cannot be asked the question. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014QwQ1tqb3831E7rezg5zqs
… deep `Owned` drops non-callable members, so a grandchild resource is not reachable from its grandparent's row: `projectRow.modules.workItems` does not exist, because the bridge URL needs the module's own id and only the module's row can supply it. The chain that does work — project row, module row, then the bridge — is the deepest navigation the SDK offers, and until now nothing exercised it against a real server. These three files do. `module-work-items` also asserts the flat call reaches the same bridge, since these are the URLs with three ids in front and the most room to be self-consistently wrong. The cycles/modules/milestones spec table grows the same flat/navigated pair the shared `SPECS` has; the body runs navigated, and the uuid-vs-project-key check is flat on both sides with a note saying why it has to be. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014QwQ1tqb3831E7rezg5zqs
Comments, links, worklogs, attachments, activities, relations and dependencies
are all grandchildren of the project: their URLs carry the work item's id, and
`Owned` drops non-callable members, so `projectRow.workItems.links` does not
exist and never could. The chain that does work is project row, work item row,
then the family — `workItem.links.create({ url })` — and these suites now drive
it that way, which is both the shape the README leads with and the deepest
navigation the SDK offers.
`work-item-relations` also stops reading the relation definitions through the
raw transport: `v2.workspaces.workItemRelationDefinitions` exists on the flat
surface now, and reading it here is one more live check of its URL.
Workspace-level work item routes stay flat (they take the slug alone), as does
the uuid-vs-project-key agreement check.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014QwQ1tqb3831E7rezg5zqs
This file's whole subject is which path template a call lands on: the workspace-scoped viewset and the project-scoped one differ by governance mode, and several cases branch between the two inside a single test. Binding a row would fix the path before the branch and hide the thing under test. The properties link/unlink pair is flat for a second reason — it is a grandchild, so a project row cannot reach it at all. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014QwQ1tqb3831E7rezg5zqs
Requests, property values, linked work items, workflow states and transitions, property options and property contexts are all grandchildren: the URL carries the intermediate row's id, so the intermediate row is what has to supply it. Each suite now fetches that row and navigates from it. The property suites are where the `options` -> `propertyOptions` navigation rename earns its keep: the row already carries an API field called `options`, `loadRow` refuses to define a navigation property over real data, and the alias is what a caller actually types. Nothing exercised it live before this. Three more raw-transport PATCHes are gone, replaced by the feature and project resources they were standing in for. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014QwQ1tqb3831E7rezg5zqs
…k is navigable `create` was the only row-returning method on any `LoadsNavigableRows` subclass that returned `doCreate`'s row directly instead of loading it. A *retrieved* webhook had `.logs`; a *created* one did not — silently, and only for the caller who created a webhook and then reached for its delivery history. This is the exact failure `loaded.ts`'s own doc comment warns about: "a method that returns a plain row instead silently loses navigation." No sweep could catch it. `loaded-navigation.test.ts` asks whether the resource is navigable, and `Webhooks` is — through `list`, `iterate`, `retrieve`, `findByName` and `update`. The hole was one method wide. It surfaced only when the e2e suite tried to reach `created.logs`, which is the argument for compiling that suite against the real surface. Verified as the only instance: a sweep over every `LoadsNavigableRows` subclass for an implementation that calls a `do*` row helper without `load`/`loadPage`/ `loadIterate` now returns nothing. Presence needed care. `loadRow` narrows `$loaded.present` to the caller's `fields`, so passing them straight through would have reported `secret_key` missing on a projected create while the secret sat in the row. `secret_key` is absent from `FIELDS.webhooks_create` — it can be neither requested nor dropped — so it is added to the presence set explicitly, and a test pins that the SDK still does not claim it when the server did not send one. Proved by reverting: the three new tests fail by name (`Property 'logs' does not exist on type 'WebhookCreateResponse'`, `Property '$loaded' does not exist...`). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014QwQ1tqb3831E7rezg5zqs
Same split each time, decided by the URL rather than by taste: a collection whose route takes only the slug is driven flat, and anything whose route carries an intermediate row's id is driven off that row. `releaseTags` moved out from under `releases` during the migration and the suite follows it. A release points at a tag through its own `tag_id` and every tag route takes the slug alone, so the tag catalog is a sibling of releases, not a child — nesting it would have implied a path segment that does not exist. The release *label* catalog is the same shape, while its `add`/`remove` bridge binds the release and so comes off the row. Two more raw-transport feature PATCHes replaced by `workspaces.features.update`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014QwQ1tqb3831E7rezg5zqs
…port `full-scenario` is rewritten navigated, because it is the file that shows what the surface is for: one `workspaces.retrieve`, one `projects.create`, and from there nothing repeats an id. `create` answers a row keyed by the project's readable identifier, so every child call addresses `.../projects/EXYZ/...` without the scenario ever saying so — and it now asserts that, via `$loaded.ids`. What stays flat stays flat structurally: `wiki` is a grouping node, and `workItems.retrieveByIdentifier` deliberately hangs off the workspace so a readable key alone finds a work item with no project named anywhere. The two work item type flows stay flat for the reason the type suites do: they cross the seam governance mode moves. `tests/e2e/` now contains no `transport.request` outside the 429-retry wrapper, and no `client.v2.workspace(...)`/`.project(...)` at all — 0 call sites, down from ~30. The `@deprecated` notes on `Workspace`/`Project`/`V2Namespace.workspace` claimed that suite as their last consumer; they now say what actually holds them in place (`locators.test.ts` and `tree-walk.ts`'s triangulation), so deleting them is a deletion plus one enumeration change rather than a redesign. Left for its own change. The export snapshot moves by exactly one line: the `Webhooks` class shape hash, the intended consequence of routing `create` through `load()`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014QwQ1tqb3831E7rezg5zqs
…s` root
Two surface removals the cross-SDK parity review asked for, both of them the
same rule: a resource sits at exactly one attribute path, the one its URL names.
**The bound locator chain is deleted, not deprecated.** `V2Namespace.workspace()`,
`Workspace.ts` and `Project.ts` bound nothing — every family they held takes its
ids per call, so `workspace(slug).roles.list(slug)` passed the slug twice — and
the e2e suite stopped calling them in the previous pass (0 call sites). Python
never had them. With them goes `V2Resource`'s `scope` constructor argument, the
pre-flat mechanism that fed them: path ids now reach the kernel only as leading
positional parameters, so there is no second, invisible source that can make a
wrong URL well-formed. `Wiki`, `GroupSync` and `WorkItemTemplates` drop the
scope they were still forwarding.
**`v2.projects` was a second path to a 38-node subtree.** A project's URL is
`/workspaces/{slug}/projects/`, so `v2.workspaces.projects` is where the flat
rule puts it; the root alias contradicted that rule, disagreed with Python, and
would have made documentation generated from the two SDKs disagree too. The
project band root moves with it.
`urlForTemplate` goes as well: an unused protected escape hatch that took a URL
template inline, so a method built on it would have put its real template
outside `extraPaths` — where `tree-walk.ts`'s `templateFor` cannot see it, and
the path-id and filters sweeps would have compared that method's leading
parameters against the class `path` instead. `resource.test.ts` now exercises
the `extraPaths`/`urlFor` route the production classes actually use.
890 unit tests pass (the 10 locator tests went with the locators); tsc clean.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014QwQ1tqb3831E7rezg5zqs
The five pagination query parameters had an *exclusion* and no sweep: the generator's `NON_FILTER_QUERY_PARAMETERS` kept them out of `FILTERS` and handed them to nothing. That is a hole rather than a decision, and the Python SDK fell all the way through it — the same five names reserved, none implemented on any of its 68 list methods, and a workspace audit-log resource that could not be called at all because the server refuses the offset envelope there. The generator now emits `PAGINATION` (68 operations), so the question can be asked, and `tests/unit/v2/pagination-coverage.test.ts` asks it over every resource class: - every `offset`/`per_page`/`paginate`/`count` the golden declares is reachable; - **`paginate` obliges `cursor`** — the golden declares `cursor` nowhere, so this one is derived: a method that lets a caller opt into `paginate: "cursor"` answers with a `next_cursor`, and with no parameter to send it back that value is a dead end. Node had this on all 68 methods and Python did not; 61 params types gain `cursor`. - `iterate` must *not* accept `offset`/`count` — it advances the offset itself and unwraps the envelope `count` shapes — so its params type is now `Omit<List*Params, "offset" | "count">` on all 200 declarations, checked from both ends so a `list` cannot lose them either. Node already implemented the four declared parameters everywhere, so rule 1 passed on the first run; rules 2 and 3 named 136 and 138 offenders respectively before the fix. Proved rule 1 bites too by dropping `paginate` from `ListAuditLogsParams`: it named `AuditLogs.list` and `AuditLogs.iterate` by operation id. Reverted. `pagination.test.ts` gains the round trip the parameter exists for: take one keyset page, read its `next_cursor`, send it back, get the next page. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014QwQ1tqb3831E7rezg5zqs
Five naming divergences the cross-SDK parity review found, all of them user-visible, none of them behavioural. - **Singleton read verb.** `GroupSync/Config.get` becomes `retrieve`, so every singleton read in the SDK is spelled the same way (`features.retrieve`, `releases.changelog.retrieve`) and matches Python, which is moving to `retrieve` on the same three routes. - **Path-id words.** `Invitations.retrieve/delete` took `invite`; the resource is an invitation, so the parameter is `invitation`. `Workflows/States` took `workflowState`; the id names the *state*, not the workflow-state pairing, so it is `state`. The rule is "named after the resource, singular, no Id suffix", and Python spells both the way the rule says. - **`LoadedAutomation` -> `LoadedProjectAutomation`**, with the rest of that family's names (`ProjectAutomationIds`, `ProjectAutomationNavigation`, `PROJECT_AUTOMATION_ID_NAMES`) moving with it. The workspace-scoped twin was already `LoadedWorkspaceAutomation*`, so one half of a deliberately-diffable pair was unqualified. - **Eight bare export names get their qualifier.** The seven work-item children (`Activities`/`Attachments`/`Comments`/`Dependencies`/`Links`/`Relations`/ `WorkLogs`) and workspace `Assets` were exported unqualified. None of the eight is a *different word* from Python's — they are the same nouns with the qualifier dropped — and two of them (`Comments`, `Links`) are names `Releases` already claims, which is why that trio was aliased on export in the first place. Same treatment now: `Comments as WorkItemComments`, `Assets as WorkspaceAssets`, and `WorkLogs as WorkItemWorklogs` — one word, matching both the API and this barrel's own `ProjectWorklogs`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014QwQ1tqb3831E7rezg5zqs
…oles in the rest
**The rule that had no sweep.** "Every row-returning method on a navigable class routes
through `load()`" shipped broken once on this branch — `Webhooks.create` handed back a
plain object and nothing failed, because the navigation sweep asks its question per
*class* and the only test of this rule was scoped to `Projects`: a selection of one. The
fix commit said it had verified the class was the only instance by running exactly the
missing sweep. It ran it and did not commit it. `loader-routing.test.ts` commits it, in
two independently-derived halves: the body must reach a loader if it calls a kernel
helper that answers a row, and the signature must not declare the bare read model. Which
helpers answer a row is read off `kernel/resource.ts`'s own return types, so
`doDelete`/`doBulk*`/`doVoidAction`/`doBridge*` are exempt **by rule** and a new
row-returning helper is in scope the day it is written.
Proved three ways: reintroducing `Webhooks.create`'s exact defect (both halves name it),
breaking only the declared type (Half B alone), and adding a brand-new `doProbe` helper
to the kernel plus a method using it (the derivation picks it up and both halves fire).
**Two more sweeps, both of which found live defects.**
- `extra-paths.test.ts` — only four kernel helpers honour an `extraPaths` override, while
`templateFor` always prefers it, so a method could satisfy the path-id and filters
sweeps while building its URL from the other template. Which helpers honour it is
derived by asking which reach `urlFor`. All 13 declarations were already correct.
- `lookup-coverage.test.ts` — `doFindOne` is `list` with a filter, so a lookup keyed on a
filter the golden does not declare resolves against the *unfiltered* collection. Found
`ProjectViews` and `WorkspaceViews` filtering on `?name=` with no `findByName` at all
(both added), and settled the reported `Workflows.findByName` gap: Python resolves it
server-side against an operation whose only filter is `search`, so Node adds it
client-side, the way `Roles`/`Collections`/`WikiPages` already do. The sweep decides
which mechanism from the golden, in both directions.
**Four weaknesses in the existing sweeps.**
- `ONE_TIME_RESPONSES` was the only exemption list with no ceiling, and its reason-guard
(`documentation.includes("fields")`) is satisfied by most *correct* doc comments in
this SDK. It now carries a ceiling of 4 and a named `docPhrase` per entry, checked to
be long enough, present in that method's own documentation, and present in no other
swept method's — so boilerplate cannot satisfy it.
- Every exemption and alias list is now keyed `<module>#<Class>`, the rule `tree-walk.ts`
states and only `bands.test.ts` followed. `Comments` and `Links` are each declared
twice, so a name-keyed entry silently covered both — and `filters-coverage.test.ts`'s
`byQualified` Map dropped one of each colliding pair outright.
- `NESTED_BAND_MEMBERS_CEILING = 2` was count-equality sitting at its own size, so it
admitted a swap. It is a membership subset now.
- `fullRowOf` built its synthetic row from `retrieve`/`list` only, so a navigation
property colliding with a create-only field passed CI. It enumerates every operation
now — proved by pointing `Webhooks`' `logs` property at `secret_key`, which only
`webhooks_regenerate` projects and which the two-operation row therefore never held.
**Grandchildren through an owned view.** Python's `Owned.__getattr__` answered the
sub-resource *unbound*, so `project.estimates.points` meant different things at different
arities. Node's `Owned` is a mapped type and drops non-callable members, so the
expression never type-checked — but it handed a JavaScript consumer `undefined` and then
a `TypeError` one frame from the cause. It now refuses by name, and
`loaded-navigation.test.ts` enumerates that over every (parent, child, grandchild) triple
the tree has, distinguishing "answered undefined" from "answered a V2Resource".
**`$loaded.present` and `id`.** `LoadedMeta`'s doc claimed `id` always counts as present;
the code reports it only when the server sent it. The *contract* was wrong — a set that
reports a field the row does not have is the over-reporting this set exists to avoid — so
the doc is corrected and the behaviour pinned.
933 unit tests pass, tsc clean, lint 165/0.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014QwQ1tqb3831E7rezg5zqs
…valid types **The README Quick Start did not compile, and the gate that should have caught it was one fence marker too narrow.** The extractor matched ` ```ts `; the Quick Start was fenced ` ```typescript `; the floor assertion passed on the twelve blocks it did see. That one unchecked block named a package that does not exist (`@plane/node-sdk`), declared `const client` twice, and called `projects.list()` without its required workspace slug — the first code a new user reads. The block is fixed and the gate now matches every TypeScript fence spelling, covers `CLAUDE.md` as well, and asserts that *every* fence in both documents is accounted for as either TypeScript or shell, so a third language cannot slip past unchecked. **Prose is checked now too, as far as prose can be.** Four claims in these documents are facts about this repository rather than prose, so they are compared against it: the module specifier a sample imports (which `harness()` strips before compiling, so the type-check never saw `@plane/node-sdk` at all), the `pnpm` scripts the docs tell you to run, the repo paths they point at, and the batch caps they state. Each was wrong somewhere. **The bulk cap: two caps, both correct.** A membership bridge takes up to 100 ids (`BRIDGE_MAX_IDS`, the golden's `maxItems` on 24 schemas); a bulk write takes up to 50 (`BULK_MAX_ITEMS`, on 21). Both documents stated the bridge number as if it were the bulk one, and the README contradicted itself 95 lines later. Both numbers are documented, the distinction is spelled out, and the gate refuses any third number. **The shipped `dist/types.bundle.d.ts` was not valid TypeScript** — 14 × TS2484, in a file `plane-ee`'s web app deep-imports and feeds to an in-app editor. Thirteen are v1 params types that lost their bare identifier when a same-named v2 type became reachable, fixed with the `V2`-alias pattern already in `src/index.ts`; the fourteenth is a v1 class alias colliding with a v1 model type, fixed by giving the shadowed type its own name (`WorkItemPropertyValueMap` — the class keeps `WorkItemPropertyValues`, which is what the root already resolved to). The snapshot guard could not see any of them: it keyed its comparison map on `scope\texternal`, so where two locals backed one external it kept only the last — both snapshots held the same 14 duplicate keys, invisible on both sides of every comparison. It groups by key now, reports duplicates as their own failure, and `pnpm build` type-checks the bundle. **166 overload pairs carried JSDoc only on the narrowing overload**, so a plain call hovered with no description; and for all 66 `list` methods that comment was not a description but a note about the overload, which is what every completion list showed as the meaning of `list`. The note is `@remarks` now, the description is on both declarations, and `field-projection.test.ts` refuses both shapes. **`Owned` is a mapped type, so a navigated method is a synthesized symbol with no declaration to hang documentation on** — hovering `project.states.list` cannot show the narrowing caveat, and nothing in TypeScript short of restating all 520 signatures changes that. The note now sits on all 82 navigation properties, one hop before the call, and `loaded-navigation.test.ts` requires it on every one. Packaging: `ts-jest` moves to devDependencies (every consumer was installing it at runtime); the 638 declaration/source maps are gone, since `files` publishes `dist` only and every map referenced a `../src/*.ts` the tarball does not contain — a clean pack drops from 1280 files / 3.0 MB unpacked to 638 / 2.1 MB. `examples/` compiles under `tsconfig.jest.json` now and its broken `"../../src"` import is fixed; the publish workflow runs the unit tests before publishing; `AGENTS.MD` names the real package; the README's `pnpm lint`/`pnpm format`/`tests/page.test.ts` commands are replaced with ones that exist. 942 unit tests pass, tsc clean, lint 165/0, format clean, build green. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014QwQ1tqb3831E7rezg5zqs
…es in the docs gate
`v2.BRIDGE_MAX_IDS` was documented in three places by the commit that fixed the caps
prose, and exported from none — a reader sizing a bridge call off it read `undefined`.
It is exported now, and the snapshot gains exactly that one name (1700 -> 1701).
The more useful half is the gate, which could not have caught it and could not catch
three other defects it was built for. Each hole is closed and each is proved by
reintroducing the exact defect, watching the check name it, and reverting:
- **Caps were context-blind.** The check held a set `{50, 100}` and skipped any stated
number in it, so documenting the bulk cap as 100 — lane 3's HIGH finding — passed.
Stated numbers are now bound to the nearest cap constant named beside them and
compared against *that* constant's value. Proved both ways: `100` next to
`BULK_MAX_ITEMS` and `50` next to `BRIDGE_MAX_IDS` each fail by name.
- **The fence census could not see an untagged fence** (` ```(\w+) ` needs a language
word), so a broken sample inside one was invisible. Fences are now paired by scanning
lines, and every fence must carry a language the gate accounts for. Proved with a
bare ``` fence containing a type error.
- **Imports were stripped before compiling**, so a sample could import a name the
package does not export. Imports are now hoisted, merged and compiled with the
published specifier rewritten to `./src`. Proved with `ThisExportDoesNotExist`.
- **A `v2.` name in prose was compiled by nothing** — which is how the bug above
shipped. Every `` `v2.name` `` must now resolve to a module export or a property of
the client's `v2` namespace. Proved with `v2.raiseForFailuresOnEveryRow`.
`AGENTS.MD` joins the gate, and its eight TypeScript fences were the reason to. They
described a design that was sketched and never built: a `fetch`-based `BaseResource`,
`ProjectApi`/`WorkItemApi`, an `src/Configuration.ts` with `basePath` and no
`validate()`. None of it compiles, and none of it could be corrected into truth — an
agent following it wrote code this repository rejects. The architecture sketches are
replaced by a pointer to the two documents that are kept true (`CLAUDE.md`,
`README.md`), a real directory tree, and two compiled usage samples; the conventions
section it exists for is retained.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014QwQ1tqb3831E7rezg5zqs
…atements `tsconfig.jest.json` is the config that proves `src`, `tests/e2e` and `examples` type-check together, and no gate invoked it: `pnpm build` compiles `tsconfig.json` (`src/**/*` only), ts-jest reads the file for `compilerOptions` and ignores its `include`, and no workflow ran a bare `tsc`. So `examples/` was compiled by nothing at all — re-breaking it was invisible to CI. `pnpm check:types` now runs it, before the build so a type error is reported as one; proved by restoring the `@makeplane/...` import in `examples/bootstrap-project.ts` and watching CI's command fail on it. The rest are one-liners that had gone stale: - `examples/README.md` still told new examples to import from `'../../src'` — two levels up, one more than there is — the specifier that was just corrected everywhere else. - `extra-paths.test.ts` said "13 declarations across 13 classes"; the population is 15. The number is gone rather than corrected: the sweep enumerates, so a count in a comment is only a second copy of a fact nothing keeps true, and the floor's job is just to make an empty derivation red. - `workflows.e2e.test.ts` still called its local `workflowState` after the path-id rename moved that word to `state`; it is `attachedState` now, matching the `attach` verb that produces it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014QwQ1tqb3831E7rezg5zqs
…pinned
**Singleton read verbs.** `GroupSyncConfig` spelled its read `get` where Python spelled
the same endpoint `retrieve`; the fix was a one-line edit and a one-line test change, so
the next divergence would land in silence. `operations-coverage.test.ts` cannot help —
it only asks that a method name match *some* declared `operations` key, which
`operations = { get: … }` plus `get()` satisfies in perfect self-consistency.
`singleton-verbs.test.ts` enumerates instead, and needs no list at either end: which
helpers are singleton helpers, and the default `action` each declares, are read off
`kernel/resource.ts`; what a method must be called is the action its own call sends. So
`Permissions.me`, `UsersMe.me` and the dict-shaped `WorkItemDependencies.list` are not
exceptions — each already agrees with itself — and a method called `get` that sends
`"retrieve"` cannot. Proved by restoring `GroupSyncConfig.get` (both the rule and the
blunter "no `get` anywhere on v2" fire by name) and by renaming `Features.retrieve`.
**Names the SDK says out loud.** The grandchild refusal told the reader to "call
Comments flat with every id" — a symbol the export surface does not contain, since the
class is exported as `WorkItemComments`. The one instruction the message exists to give
sent them nowhere, and two doc comments did the same: `UserAssets.ts` pointed at
`Assets.create`, and `WorkItems/Dependencies.ts` at `{@link Relations}`, which is
neither exported nor in scope in that file.
Eleven classes are aliased this way. `constructor.name` cannot see the alias and does
not survive a minifier, and the barrel cannot be read from the kernel (`../index`
imports it), so each aliased class now declares a `publicName` static and
`exported-names.test.ts` pins every one of them to the barrel in both directions —
an alias with no static fails, a static naming a non-export fails — and requires every
resource in the tree to resolve to a real export name, which is what makes any message
built from `exportedName()` importable by construction. A second sweep refuses an
unqualified spelling in a symbol position (`` `Comments` ``, `{@link Comments}`)
anywhere under `src/api/v2` except the barrel and the declaring file, both derived
rather than listed. Proved by dropping a static, by pointing one at a name the barrel
does not export, and by restoring `{@link Relations}`.
The snapshot keeps its 1701 entries; eleven hashes move, one per new static.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014QwQ1tqb3831E7rezg5zqs
The first live run surfaced this: `useV2Project` registers its project-delete `afterAll` when it is called at the top of the describe, and Jest runs afterAll hooks in declaration order, so the fixture's project deletion ran before this file's work item deletion. The work item was already gone with its project, and the delete 404'd, failing the suite after all four tests had passed. Project deletion cascades to its work items, so the hook was redundant as well as misordered. Only this suite deleted a project-scoped child in its own hook; the other ten suites that register an afterAll alongside the fixture clean up workspace-scoped resources, which survive the project and are unaffected. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014QwQ1tqb3831E7rezg5zqs
Only conflict was the version: this branch had already bumped to 0.3.0 for the v2 surface, while main remained at 0.2.14. Kept 0.3.0 — main has published nothing past 0.2.14, and this adds an entire namespace. The other commit on main, "add parent to work item comment model and create request" (#57), touches src/models/Comment.ts, which is the v1 model. This branch never touched it, so it merged cleanly and both `parent` fields survive. No v2 mirror is needed: the v2 golden does not declare `parent` on work item comment create, so the capability does not exist on that surface yet. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014QwQ1tqb3831E7rezg5zqs
Two CI failures, one that fired and one that was waiting to. The build failed because the export snapshot was generated before merging main. That merge brought in `parent` on WorkItemComment and WorkItemCommentCreateRequest (v1 models, via #57), so both declarations legitimately changed shape while the snapshot still recorded the old hashes. Regenerated: exactly those two entries move, count unchanged at 1701, no additions and no removals. The guard was right. The second had not surfaced yet. `v2-golden-drift` regenerates constants.ts and requires it byte-identical, and the generator records its source path in the file header -- so the committed spelling must match the one CI passes, which the workflow documents as `../plane-ee/apps/api/plane/api_v2/core/schema/openapi`. An earlier commit here (3aab31d) rewrote that header to `../plane-ee-preview/...` when the golden was regenerated from a preview checkout, which would have failed the drift check the moment PLANE_EE_CHECKOUT_TOKEN was configured; the job is skipped without it, so nothing reported it. Regenerated against the same preview content with the path spelled as CI spells it: the header is the only line that differs, all 407 operations are identical. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014QwQ1tqb3831E7rezg5zqs
Four .superpowers/*.md files were committed: internal working notes from this migration's implementation passes, not documentation anyone consuming or reviewing this SDK needs. They were tracked here only because .superpowers/ was missing from .gitignore -- the Python SDK already ignores it, so nothing equivalent was ever committed there. Untracked and ignored; the files stay on disk locally. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014QwQ1tqb3831E7rezg5zqs
CI's last run failed 8 v1 tests plus the file's own teardown with `HttpError: Request failed with status code 429`. Nothing in `project.test.ts` was wrong: v1 goes through `BaseResource` -> the *global* axios instance, which had no rate-limit handling at all, while every v2 file was already waiting out its 429s through `createV2Client`'s wrapper. One API key carries the whole run and the server throttles per key, so the v1 suite was being failed by the quota the v2 suites spend. Lifts that policy into `tests/e2e/support/rate-limit.ts` and gives both versions a way in: - v2 keeps wrapping `transport.request` (its axios instance is private, and the decoded 429 lands on `PlaneApiError`, whose `detail` states the wait). - v1 installs a global axios response interceptor, which sees the response and so reads the real `Retry-After` header DRF sets. Installed from the e2e file rather than `tests/helpers/test-utils.ts`, which the nock-driven unit suites also import. The fallback for a 429 that states no wait was a flat 15s; it is now exponential (2s, 4s, 8s, 16s, 32s), which spans the server's one-minute window. Bounds are unchanged in spirit and tightened in fact: 6 attempts, 150s of waiting per request, 60s per individual wait. Past those the 429 propagates and the test fails, because at that point the run is not being slowed by the limiter, it is being defeated by it. Verified against nock: a 429 carrying `Retry-After` is replayed on both paths, and a 500 (v1) / 402 (v2) is not retried. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014QwQ1tqb3831E7rezg5zqs
…orkspace CI runs this suite against a workspace with no licence for several EE features, so the server answers `402 payment_required` and 68 tests across 12 files failed for a reason that has nothing to do with the SDK. That is an environment limitation, and the Python suite has treated it as one since `tests/v2/integration/_guard.skip_absent_capability`. This is the Node counterpart of that guard, and it keeps the property the guard exists for: a suite that skips silently is worse than one that fails. `tests/e2e/v2/support/capability.ts` holds it. Three rules: - **Only a 402 arms a gate.** There is no hand-arm switch; both entry points take a thrown error and check `status === 402`. A 400, 403, 404, 409 or 500 is rethrown untouched and stays a failure. The status alone decides, because a 402 has been observed live carrying `code: "server_error"` rather than `payment_required` (the artifacts route), and matching on the code would have turned that missing licence back into a red suite. - **Every sit-out is announced**, tagged `server capability absent -- ` and naming the flag the server refused on, per test and once per file. - **A 402 mid-test still tears down.** `capability.it` catches the refusal only after the body's own `try/finally` has unwound. Files gated, each driven by a 402 seen in CI's log rather than by guessing: releases, customers, customer-properties, automations (two flags, two gates), work-item-templates, workflows, audit-logs, project-worklogs-summary, artifacts, the worklogs describe of work-item-subresources, and the custom property steps of full-scenario and project-work-item-types-flow. Two tests are deliberately left ungated because they validate client-side and never reach the network: `customers` "rejects an unknown fields value" and `workflows` "rejects an unknown order_by". group-sync and work-item-relation-definitions already swallowed their 402s into a bare `return`, which is the silent skip the Python guard converts to a failure; they now announce through the same gate. Proven by forcing the transport's answer: forcing 402 on `/releases` sits all 9 tests out naming FeatureFlag.RELEASES; forcing 403 or 500 on the same path fails all 9; forcing 402 on the gated `beforeAll` sits the file out, while 500 there still fails it; and forcing 402 on a release *retrieve* after a successful create leaves the workspace's release count unchanged, so the `finally` ran. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014QwQ1tqb3831E7rezg5zqs
This test guards with `skipUnlessMode(mode, "project", ...)`, and every workspace it had run against until now was in workspace mode -- so it returned early and its assertions had never executed. CI's workspace is in project mode, ran them for the first time, and the one on the response body was wrong. `enable` selects the row it answers with `is_epic=False, is_default=True` and returns that, creating the Epic separately via `_ensure_epic_exists`. So `enabledAgain.is_epic` is false by construction and can never be true; the old assertion could not have passed anywhere the test actually ran. Now pins the real contract on both sides: the response is the default type, and the Epic is observable through the list -- which is the invariant the test name already claimed. Strictly more than the old assertions checked, not less. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014QwQ1tqb3831E7rezg5zqs
What this is
The v2 surface, reshaped onto variant F — the design approved by the team. Supersedes the earlier locator-based design this PR originally carried; the review comments above predate it.
There are two ways in, and they compose:
The premise: there is no such thing as an object without data. A retrieved row is both data and a place to navigate from.
Scope
client.v2Idsuffix —retrieve(slug, project, state)Field projection is compile-time
fieldsnarrows the return type viaPick<T, F | "id">, so reading a field you did not request is a type error, notundefinedat runtime. This differs deliberately from the Python SDK, which raisesFieldNotRequestedat runtime — each language uses its strongest available check.Six enforcement sweeps
Tasks covering 84 of the 90 classes ran without per-task review; these sweeps were the compensating control. Each enumerates every resource rather than selecting by shape — six separate defects in this migration came from a check that selected its own subject set and silently skipped members:
expand/fieldsORDER_BYlist/iterate/create/update/upsertnarrow soundlyEvery sweep was proved by introducing the violation it targets and confirming it fails by name.
Verified against a live server
43/50 e2e suites, 258/267 testsagainst a running Plane instance. Zero SDK defects. The remaining failures all trace to one operation —workspaces_retrieve— that the test instance's API predates; the other 406 pass.Unit: 955 passing.
tscclean acrosssrc+tests/e2e+examples. Lint at baseline. Bundle type-checks (it previously shipped 14 invalid declarations).Breaking changes
None to any published surface — the v2 namespace has never been published (npm is at 0.2.14, whose tarball contains zero
dist/api/v2files). Within v2:GroupSyncConfig.getis nowretrieve, matching every other singleton; the retired locator chain (client.v2.workspace(...)) is deleted.Known follow-ups
Ownedis a mapped type, so hovering a navigated method shows no JSDoc — TypeScript attaches none to a synthesized symbol. The caveat sits one hop earlier, on all 82 navigation properties.exportsmap /sideEffects/ ESM question is left alone deliberately:plane-ee's web editor deep-importsdist/types.bundle.d.ts?raw, so changing it is a packaging policy call.🤖 Generated with Claude Code
https://claude.ai/code/session_014QwQ1tqb3831E7rezg5zqs