From ace45d6f4ad30d52a686b6460a859ca20a5dde7e Mon Sep 17 00:00:00 2001 From: aefreedman Date: Mon, 28 Sep 2026 22:01:05 -0400 Subject: [PATCH 1/3] Scope deck searches server-side and correct debug query ordering --- CHANGELOG.md | 11 ++ README.md | 2 +- docs/resolvable-inbox-heuristics.md | 2 +- .../references/card-operations.md | 2 +- src/codecks-core.ts | 67 +++++++-- tests/card-search-preview.test.ts | 131 ++++++++++++++++-- tests/debug-tool-loading.test.ts | 87 +++++++++++- 7 files changed, 273 insertions(+), 29 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f0a5333..3e43807 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,17 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project follows semantic versioning for public package releases. +## Unreleased + +### Changed + +- Push resolved Deck filters into paged card searches while retaining defensive Deck and local milestone checks; a Deck-scoped scan budget now counts server-returned Deck rows without broad fallback on permission or query errors. Milestone-only searches remain account-visible scans. +- Bound optional resolvable diagnostic relation probes to schema-confirmed ordered has-many relations; unsupported relations fail locally rather than sending invalid pagination. + +### Fixed + +- Preserve structured permission/query diagnostics and bounded scan progress when a scoped card page fails, without including earlier card rows in errors. + ## [0.14.0] - 2026-09-28 ### Added diff --git a/README.md b/README.md index a585570..b71de0e 100644 --- a/README.md +++ b/README.md @@ -188,7 +188,7 @@ Use `codecks_card_get_batch` for full details of 1-25 known short-code and/or `s Use `codecks_card_get_formatted` when the agent needs to present human-readable card details to a user. -Use `codecks_card_search` when title/location criteria may match multiple cards and the agent needs disambiguation. Supplying `deck` or `milestone` without `location` infers the corresponding scope instead of running a broad search. Deck and milestone filters can be combined for intersection searches such as cards in the Alpha milestone and Dev deck. If an agent accidentally passes a visible deck name such as `Design Docs` or `Vertical Slice` as `location`, the registration layer treats it as `deck` when no explicit deck/milestone was supplied. Title searches support `*` / `?` glob wildcards and accent/punctuation-insensitive matching. Use `text` with `searchIn: "title_or_content"` (or `content`) for body searches, and `includeDone: false` for open/undone-only searches. Structured search results include planning metadata such as effort, card type, child count, deck/milestone identity, matched fields, update dates, and reusable `cardRef` / `accountSeqRef` values when Codecks returns those fields. Bounded scans report `scannedCards`, `complete`, `scanLimitReached`, request count, queue wait, and elapsed time. `complete` means complete only within token-visible projects, not necessarily the organization; invisible cards are not deleted cards. Account scans are concurrency-bounded: do not launch parallel full-account or high-`scanLimit` searches; prefer one shared-scope bulk preview or narrow sequential searches. Search output defaults to compact mode and caps returned card rows to protect session context; use `outputMode: "counts"` for bulk scope/effort analysis and `outputMode: "detailed"` only when every returned card row is truly needed. No-match searches return successful empty results with search tips instead of tool errors. +Use `codecks_card_search` when title/location criteria may match multiple cards and the agent needs disambiguation. Supplying `deck` or `milestone` without `location` infers the corresponding scope instead of running a broad search. Deck and milestone filters can be combined for intersection searches such as cards in the Alpha milestone and Dev deck. If an agent accidentally passes a visible deck name such as `Design Docs` or `Vertical Slice` as `location`, the registration layer treats it as `deck` when no explicit deck/milestone was supplied. Title searches support `*` / `?` glob wildcards and accent/punctuation-insensitive matching. Use `text` with `searchIn: "title_or_content"` (or `content`) for body searches, and `includeDone: false` for open/undone-only searches. Structured search results include planning metadata such as effort, card type, child count, deck/milestone identity, matched fields, update dates, and reusable `cardRef` / `accountSeqRef` values when Codecks returns those fields. Bounded scans report `scannedCards`, `complete`, `scanLimitReached`, request count, queue wait, and elapsed time. `complete` means complete only within token-visible projects, not necessarily the organization; invisible cards are not deleted cards. Account scans are concurrency-bounded: do not launch parallel full-account or high-`scanLimit` searches; prefer one shared-scope bulk preview or narrow sequential searches. Search output defaults to compact mode and caps returned card rows to protect session context; use `outputMode: "counts"` for bulk scope/effort analysis and `outputMode: "detailed"` only when every returned card row is truly needed. No-match searches return successful empty results with search tips instead of tool errors. Deck-scoped paged account-card searches now send the resolved deck ID to Codecks and verify returned deck relations locally; `scanLimit` counts server-returned deck rows, not all account cards. A combined Deck + milestone search sends only the Deck filter and checks the milestone locally; milestone-only searches still scan token-visible account cards. A rejected scoped query fails rather than silently falling back to a broad scan. Use `codecks_card_bulk_create` and `codecks_card_bulk_update` for CSV/import-style tracker work after mapping source rows into strict card objects. Both default to dry-run and apply must pass that dry run's `previewFingerprint` as `expectedPreviewFingerprint`; a matching authorization covers the matching apply without another interaction, but ask again when the preview differs or exceeds the approved scope. Unsupported fields are rejected before requests; use `assigneeId` from `codecks_user_lookup`, not `assignee`. After approval, submit one bulk operation: the package paces every physical request at 40 requests per five seconds, so do not manually chunk records or count requests. diff --git a/docs/resolvable-inbox-heuristics.md b/docs/resolvable-inbox-heuristics.md index 7be7755..24f0075 100644 --- a/docs/resolvable-inbox-heuristics.md +++ b/docs/resolvable-inbox-heuristics.md @@ -99,4 +99,4 @@ Enable debug tools and use: - `codecks_debug_logged_in_user_resolvable_participation` - `codecks_debug_logged_in_user_resolvables` -These tools are intended for low-frequency diagnostics only. +These tools are intended for low-frequency diagnostics only. Optional relation probes paginate only schema-confirmed sortable has-many relations; unsupported, single-row, or array-backed relations report a local unsupported probe result instead of sending an invalid `$limit` query. Probe failures do not establish that a user has no notifications or participants. diff --git a/skills/using-codecks/references/card-operations.md b/skills/using-codecks/references/card-operations.md index 640a0d8..8eae61f 100644 --- a/skills/using-codecks/references/card-operations.md +++ b/skills/using-codecks/references/card-operations.md @@ -22,7 +22,7 @@ Read this reference for card lookup, creation, ordinary updates, lifecycle chang ## Search - `codecks_card_search` excludes archived/deleted cards by default; set `includeArchived=true` only when explicitly needed. -- Supplying a Deck or milestone without `location` infers that scope. Deck and milestone may be combined for an intersection. +- Supplying a Deck or milestone without `location` infers that scope. Deck-scoped paged account-card searches push the resolved Deck ID to the server, then defensively verify each result; `scanLimit` counts returned Deck rows. Deck + milestone intersections push only the Deck filter and verify the milestone locally; milestone-only searches still scan token-visible account cards. A rejected scoped query never falls back to a broad scan. - `title` supports partial and glob-style `*`/`?` matching. Use `text` with `searchIn: "title_or_content"` or `"content"` for bodies. - Compact output is the default. Prefer `outputMode: "counts"` for aggregate analysis and `"detailed"` only when every row is required. - No-match results are successful empty searches. Inspect criteria/tips rather than treating them as failures. diff --git a/src/codecks-core.ts b/src/codecks-core.ts index daa42c1..7e5407b 100644 --- a/src/codecks-core.ts +++ b/src/codecks-core.ts @@ -5371,7 +5371,12 @@ const relationMatchesLookupId = (value: unknown, lookupId: string | number): boo const cardMatchesClientScopes = (card: CodecksEntity, scopes: ClientCardScopeFilter[]): boolean => - scopes.every((scope) => relationMatchesLookupId(card[scope.type], scope.id)); + scopes.every((scope) => + { + const values = [card[scope.type], card[`${scope.type}_id`], card[`${scope.type}Id`]] + .filter((value) => value !== undefined && value !== null); + return values.length > 0 && values.every((value) => relationMatchesLookupId(value, scope.id)); + }); type TextSearchMatcher = { raw: string; @@ -5872,6 +5877,7 @@ const fetchCardMatches = async (args: CardSearchParams): Promise = { + participants: { order: "-firstJoinedAt", fields: ["userId", "resolvableId", "firstJoinedAt"] }, + entries: { order: "-createdAt", fields: ["entryId", "createdAt"] }, +}; +const userDebugRelations: Record = { + participations: { order: "-firstJoinedAt", fields: ["userId", "resolvableId", "firstJoinedAt"] }, +}; + export const debug_logged_in_user_resolvable_participation = tool({ description: "Probe participant/subscription/opt-out signals for logged-in-user attention-worthy resolvables and estimate bubble states.", args: { @@ -12929,13 +12958,21 @@ export const debug_logged_in_user_resolvable_participation = tool({ continue; } - const relationKey = relationQuery(relationName, { $limit: relationProbeLimit }); + const supported = resolvableDebugRelations[relationName]; + if (!supported) + { + const error = `Unsupported resolvable probe relation '${relationName}'; only known ordered hasMany relations may be paginated.`; + warnings.push(error); + resolvableRelationProbes.push({ relation: relationName, ok: false, error }); + continue; + } + const relationKey = relationQuery(relationName, { $order: supported.order, $limit: relationProbeLimit }); const probeQuery = { [sampleKey]: [ "id", "context", { - [relationKey]: ["id", "entryId", "name", "fullName"], + [relationKey]: supported.fields, }, ], }; @@ -13376,7 +13413,15 @@ export const debug_logged_in_user_resolvables = tool({ continue; } - const relationKey = relationQuery(relationName, { $limit: relationProbeLimit }); + const supported = userDebugRelations[relationName]; + if (!supported) + { + const error = `Unsupported loggedInUser probe relation '${relationName}'; only known ordered hasMany relations may be paginated.`; + warnings.push(error); + relationProbes.push({ relation: relationName, ok: false, error }); + continue; + } + const relationKey = relationQuery(relationName, { $order: supported.order, $limit: relationProbeLimit }); const probeQuery = { _root: [ { @@ -13384,7 +13429,7 @@ export const debug_logged_in_user_resolvables = tool({ "id", "name", { - [relationKey]: ["id"], + [relationKey]: supported.fields, }, ], }, diff --git a/tests/card-search-preview.test.ts b/tests/card-search-preview.test.ts index 72a0151..6545e91 100644 --- a/tests/card-search-preview.test.ts +++ b/tests/card-search-preview.test.ts @@ -10,6 +10,9 @@ type MockCard = Record; type FetchMockOptions = { rejectCardQueries?: boolean; + ignoreDeckFilter?: boolean; + denyCardQueries?: boolean; + denyPageAtOffset?: number; }; const deck = { id: "deck-dev", title: "Dev", accountSeq: 2 }; @@ -29,6 +32,12 @@ const getCardsRelationKey = (query: any): string | undefined => { return accountEntries.flatMap((entry: any) => Object.keys(entry ?? {})).find((key: string) => key.startsWith("cards(")); }; +const getCardsFilters = (request: any): Record => { + const key = getCardsRelationKey(request.query); + assert.ok(key, "expected a paged account cards relation"); + return JSON.parse(key.slice("cards(".length, -1)); +}; + const makeCardsPayload = (relationKey: string, cards: MockCard[]) => ({ data: { _root: { @@ -70,13 +79,22 @@ const installFetchMock = (cards: MockCard[], options: FetchMockOptions = {}) => if (options.rejectCardQueries) { throw new Error("mock Codecks outage"); } + if (options.denyCardQueries) { + return new Response(JSON.stringify({ error: "missing_scope", path: "_root.account.cards", requiredScope: "card:read" }), { status: 403 }); + } const relationKey = getCardsRelationKey(body.query); assert.ok(relationKey, `expected cards relation in ${queryText}`); - const filters = JSON.parse(relationKey.slice("cards(".length, -1)); + const filters = getCardsFilters(body); + if (options.denyPageAtOffset !== undefined && Number(filters.$offset ?? 0) >= options.denyPageAtOffset) { + return new Response(JSON.stringify({ error: "missing_scope", path: "_root.account.cards", requiredScope: "card:read" }), { status: 403 }); + } + const scopedCards = options.ignoreDeckFilter || filters.deckId === undefined + ? cards + : cards.filter((card) => card.deck === filters.deckId || card.deck_id === filters.deckId || card.deckId === filters.deckId); const offset = Number(filters.$offset ?? 0); - const limit = Number(filters.$limit ?? cards.length); - const page = cards.slice(offset, offset + limit); + const limit = Number(filters.$limit ?? scopedCards.length); + const page = scopedCards.slice(offset, offset + limit); return new Response(JSON.stringify(makeCardsPayload(relationKey, page)), { status: 200, headers: { "content-type": "application/json" }, @@ -171,8 +189,8 @@ const cards: MockCard[] = [ assert.equal(payload.ok, true); assert.equal(payload.action, "card-search"); assert.equal(payload.data.matches, 5); - assert.ok(!JSON.stringify(requests[1].query).includes("deckId"), "deck-scoped search should avoid the live-API-unsafe deckId cards filter"); - assert.ok(JSON.stringify(requests[1].query).includes('"deck"'), "deck-scoped search should request deck relation data for client-side filtering"); + assert.equal(getCardsFilters(requests[1]).deckId, "deck-dev", "resolved deck is pushed into the paged server filter"); + assert.ok(JSON.stringify(requests[1].query).includes('"deck"'), "deck relation remains selected for defensive verification"); const first = payload.data.cards[0]; assert.equal(first.shortCode, "$14j"); @@ -194,8 +212,9 @@ const cards: MockCard[] = [ assert.equal(payload.ok, true); assert.equal(payload.action, "card-search"); - assert.ok(!JSON.stringify(requests[1].query).includes("milestoneId"), "milestone-scoped search should avoid the live-API-unsafe milestoneId cards filter"); + assert.equal(getCardsFilters(requests[1]).milestoneId, undefined, "milestone pushdown stays unverified and local"); assert.ok(JSON.stringify(requests[1].query).includes('"milestone"'), "milestone-scoped search should request milestone relation data for client-side filtering"); + assert.equal(getCardsFilters(requests[1]).deckId, undefined, "milestone-only search remains an account-visible scan"); } { @@ -323,12 +342,15 @@ const cards: MockCard[] = [ } { - installFetchMock(cards); + const requests = installFetchMock(cards); const intersectionText = String(await core.card_search.execute({ location: "milestone", deck: "Dev", milestone: "Alpha", format: "json" })); const intersection = parseStructuredJson(intersectionText); assert.equal(intersection.ok, true); assert.equal(intersection.data.matches, 1); assert.equal(intersection.data.cards[0].title, "Eligible card"); + assert.equal(getCardsFilters(requests[2]).deckId, "deck-dev", "only the verified deck predicate is pushed down"); + assert.equal(getCardsFilters(requests[2]).milestoneId, undefined, "milestone is still verified after the deck-scoped read"); + assert.equal(intersection.data.scannedCards, 5, "intersection coverage counts deck rows before local milestone filtering"); const ignoredText = String(await core.card_list_missing_effort.execute({ location: "hand", deck: "Dev", format: "json" })); const ignored = parseStructuredJson(ignoredText); @@ -347,6 +369,61 @@ const cards: MockCard[] = [ assert.match(payload.error.message, /mock Codecks outage/); } +{ + const requests = installFetchMock(cards, { denyCardQueries: true }); + const denied = parseStructuredJson(String(await core.card_search.execute({ deck: "Dev", format: "json" }))); + assert.equal(denied.ok, false); + assert.equal(denied.error.category, "missing_scope"); + assert.equal(denied.error.apiCode, "missing_scope"); + assert.equal(denied.error.apiPath, "_root.account.cards"); + assert.equal(requests.length, 2, "a rejected scoped query never falls back to an account-wide query"); + assert.equal(getCardsFilters(requests[1]).deckId, "deck-dev"); +} + +{ + const requests = installFetchMock(cards, { denyPageAtOffset: 2 }); + const denied = parseStructuredJson(String(await core.card_search.execute({ deck: "Dev", pageSize: 2, scanLimit: 5, format: "json" }))); + assert.equal(denied.ok, false); + assert.equal(denied.error.category, "missing_scope"); + assert.equal(denied.error.scannedCards, 2, "earlier pages remain measured, but are not presented as complete matches"); + assert.equal(denied.error.requestsAttempted, 2); + assert.equal(denied.error.complete, false); + assert.equal(denied.error.cards, undefined, "private earlier-page rows are not copied into error details"); + assert.equal(requests.length, 3); + assert(requests.slice(1).every((request) => getCardsFilters(request).deckId === "deck-dev"), "no failed page broadens to account scan"); + const preview = parseStructuredJson(String(await core.card_list_missing_effort.execute({ deck: "Dev", pageSize: 2, scanLimit: 5, format: "json" }))); + assert.equal(preview.ok, false); + assert.equal(preview.error.category, "missing_scope"); + assert.equal(preview.error.scannedCards, 2); +} + +{ + const savedFetch = globalThis.fetch; + let requests = 0; + globalThis.fetch = (async (_url: string | URL | Request, init?: RequestInit) => { + requests++; + const queryText = JSON.stringify(JSON.parse(String(init?.body ?? "{}")).query); + const invalidLimit = queryText.includes('$limit'); + return new Response(JSON.stringify({ + error: invalidLimit ? "invalid_limit" : "invalid_order", + path: "_root.account.cards", + message: invalidLimit ? "$limit requires $order" : "$order must name a sortable field", + }), { status: 400 }); + }) as typeof fetch; + try { + for (const [filter, code] of [[{ $limit: 2 }, "invalid_limit"], [{ $order: { field: "createdAt" } }, "invalid_order"]] as const) { + const key = `cards(${JSON.stringify(filter)})`; + const result = parseStructuredJson(String(await core.query.execute({ query: { _root: [{ account: [{ [key]: ["cardId"] }] }] } }))); + assert.equal(result.ok, false); + assert.equal(result.error.category, "api_error"); + assert.equal(result.error.apiCode, code); + assert.equal(result.error.apiPath, "_root.account.cards"); + assert.match(result.error.validationMessage, /\$limit requires \$order|\$order must name/); + } + assert.equal(requests, 2, "invalid query diagnostics are preserved without broad retries"); + } finally { globalThis.fetch = savedFetch; } +} + { installFetchMock([]); const text = String(await core.card_search.execute({ deck: "Dev", format: "json" })); @@ -394,23 +471,49 @@ const cards: MockCard[] = [ { const pagedCards = [ - { ...cards[5], cardId: "other-1", accountSeq: 301 }, - { ...cards[5], cardId: "other-2", accountSeq: 302 }, - { ...cards[0], cardId: "target-after-page-one", accountSeq: 303, title: "Late scoped match" }, + ...Array.from({ length: 100 }, (_, index) => ({ ...cards[5], cardId: `other-${index}`, accountSeq: 300 + index })), + { ...cards[0], cardId: "target-after-old-account-window", accountSeq: 500, title: "Late scoped match" }, ]; const requests = installFetchMock(pagedCards); - const text = String(await core.card_search.execute({ deck: "Dev", pageSize: 2, scanLimit: 10, format: "json" })); + const text = String(await core.card_search.execute({ deck: "Dev", pageSize: 2, scanLimit: 2, format: "json" })); const payload = parseStructuredJson(text); assert.equal(payload.ok, true); assert.equal(payload.data.matches, 1); assert.equal(payload.data.cards[0].title, "Late scoped match"); - assert.equal(payload.data.scannedCards, 3); + assert.equal(payload.data.scannedCards, 1, "server-scoped budget counts only the returned deck row, not 100 unrelated cards"); assert.equal(payload.data.complete, true); assert.equal(payload.data.scanLimitReached, false); const cardRequests = requests.map((request) => getCardsRelationKey(request.query)).filter(Boolean) as string[]; - assert.equal(cardRequests.length, 2); - assert.match(cardRequests[1], /"\$offset":2/); + assert.equal(cardRequests.length, 1); + assert.equal(getCardsFilters(requests[1]).deckId, "deck-dev"); +} + +{ + const requests = installFetchMock([ + { ...cards[5], cardId: "wrong-deck" }, + { ...cards[0], cardId: "right-deck", deck: undefined, deck_id: "deck-dev" }, + ], { ignoreDeckFilter: true }); + const result = parseStructuredJson(String(await core.card_search.execute({ deck: "Dev", pageSize: 3, scanLimit: 3, format: "json" }))); + assert.equal(result.data.matches, 1, "unexpected server rows are still rejected by client scope verification"); + assert.equal(result.data.cards[0].title, "Eligible card"); + assert.equal(result.data.scannedCards, 2); + assert.equal(result.data.complete, true); + assert.equal(requests.length, 2); +} + +{ + installFetchMock([ + { ...cards[5], cardId: "other-first" }, + { ...cards[0], cardId: "archived-deck-card", visibility: "archived" }, + { ...cards[0], cardId: "visible-deck-card" }, + ]); + const result = parseStructuredJson(String(await core.card_search.execute({ deck: "Dev", pageSize: 2, scanLimit: 2, format: "json" }))); + assert.equal(result.data.scannedCards, 2, "the scoped budget counts server rows even when archive filtering drops one"); + assert.equal(result.data.matches, 1); + assert.equal(result.data.complete, false); + assert.equal(result.data.scanLimitReached, true); + assert.match(result.warnings[0], /incomplete/i); } { diff --git a/tests/debug-tool-loading.test.ts b/tests/debug-tool-loading.test.ts index a0343e0..0e94441 100644 --- a/tests/debug-tool-loading.test.ts +++ b/tests/debug-tool-loading.test.ts @@ -24,4 +24,89 @@ const result = await loader.execute("debug-loader", { query: "diagnostic logged assert.deepEqual(result.details.added, [debugNames[1]], "an explicitly requested registered diagnostic tool can activate"); assert(harness.getActiveTools().includes(debugNames[1])); -console.log("PASS: Codecks debug dynamic registration test succeeded"); +// Optional relation probes must only page schema-confirmed hasMany relations with an order. +const originalFetch = globalThis.fetch; +const core = await import("../src/codecks-core.ts"); +const priorProfile = process.env.CODECKS_PROFILE; +const priorToken = process.env.CODECKS_PROFILE_PERSONAL_TOKEN; +const priorAccount = process.env.CODECKS_ACCOUNT; +const priorProvider = process.env.CODECKS_CREDENTIAL_PROVIDER; +try { + process.env.CODECKS_ACCOUNT = "synthetic-account"; + process.env.CODECKS_PROFILE = "PERSONAL"; + process.env.CODECKS_PROFILE_PERSONAL_TOKEN = "cdxut_synthetic-debug-profile"; + process.env.CODECKS_CREDENTIAL_PROVIDER = "environment"; + const queries: Record[] = []; + let includeActionable = false; + globalThis.fetch = (async (_url, init) => { + const query = JSON.parse(String(init?.body ?? "{}")).query as Record; + queries.push(query); + if (Object.keys(query)[0].startsWith("resolvable(")) { + const key = Object.keys(query)[0]; + const relationKey = Object.keys((query as any)[key].find((entry: unknown) => typeof entry === "object" && entry !== null))[0]; + return new Response(JSON.stringify({ data: { [key]: "res-1", resolvable: { "res-1": { id: "res-1", [relationKey]: [] } } } }), { status: 200 }); + } + if (JSON.stringify(query).includes('"account"')) { + const key = Object.keys((query._root as any)[0].account[0])[0]; + if (!includeActionable) { + return new Response(JSON.stringify({ data: { _root: { account: "account-1" }, account: { "account-1": { [key]: [] } } } }), { status: 200 }); + } + const cardFields = (query._root as any)[0].account[0][key]; + const relationKey = Object.keys(cardFields.find((entry: any) => entry && typeof entry === "object" && Object.keys(entry)[0].startsWith("resolvables(")))[0]; + const resolvableFields = cardFields.find((entry: any) => entry && typeof entry === "object" && entry[relationKey])[relationKey]; + const entryKey = Object.keys(resolvableFields.find((entry: any) => entry && typeof entry === "object" && Object.keys(entry)[0].startsWith("entries(")))[0]; + return new Response(JSON.stringify({ data: { + _root: { account: "account-1" }, account: { "account-1": { [key]: ["card-1"] } }, + card: { "card-1": { cardId: "card-1", accountSeq: 1, title: "Synthetic", status: "started", assignee: "user-1", [relationKey]: ["res-1"] } }, + resolvable: { "res-1": { id: "res-1", context: "comments", isClosed: false, [entryKey]: ["entry-1"] } }, + resolvableEntry: { "entry-1": { entryId: "entry-1", author: "user-2", createdAt: "2026-09-28T00:00:00Z", content: "Synthetic message" } }, + user: { "user-1": { id: "user-1", name: "Synthetic" }, "user-2": { id: "user-2", name: "Other" } }, + } }), { status: 200 }); + } + const entries = (query._root as any)[0].loggedInUser; + const key = entries.find((entry: unknown) => typeof entry === "object" && entry !== null); + const relationKey = key && Object.keys(key)[0]; + return new Response(JSON.stringify({ data: { + _root: { loggedInUser: "user-1" }, + user: { "user-1": { id: "user-1", name: "Synthetic", ...(relationKey ? { [relationKey]: [] } : {}) } }, + } }), { status: 200 }); + }) as typeof fetch; + const response = String(await core.debug_logged_in_user_resolvables.execute({ + scanLimit: 1, probeRelations: ["participations", "profileImage"], probeFields: [], relationProbeLimit: 2, format: "json", + })); + const parsed = JSON.parse(response.match(/```json\s*([\s\S]*?)```/)![1]); + assert.equal(parsed.ok, true); + const probe = parsed.data.relationProbes.find((entry: any) => entry.relation === "profileImage"); + assert.equal(probe.ok, false); + assert.match(probe.error, /Unsupported loggedInUser probe relation/); + const serialized = JSON.stringify(queries); + assert(!serialized.includes("profileImage"), "a single-row relation never receives collection pagination"); + assert.match(serialized, /participations\(/); + assert.match(serialized, /\\"\$order\\":\\"-firstJoinedAt\\"/); + assert.match(serialized, /\\"\$limit\\":2/); + assert.equal(queries.length, 3, "identity, empty card scan, and one safe relation probe only"); + queries.length = 0; + includeActionable = true; + const participation = String(await core.debug_logged_in_user_resolvable_participation.execute({ + scanLimit: 1, probeResolvableRelations: ["participants", "card"], probeResolvableFields: [], relationProbeLimit: 2, format: "json", + })); + const participationResult = JSON.parse(participation.match(/```json\s*([\s\S]*?)```/)![1]); + assert.equal(participationResult.ok, true); + assert.equal(participationResult.data.sampleResolvableId, "res-1"); + const skipped = participationResult.data.resolvableRelationProbes.find((entry: any) => entry.relation === "card"); + assert.equal(skipped.ok, false); + assert.match(skipped.error, /Unsupported resolvable probe relation/); + const participationQueries = JSON.stringify(queries); + assert(!participationQueries.includes('"card('), "single-row resolvable relations are not paginated"); + assert.match(participationQueries, /participants\(/); + assert.match(participationQueries, /\\"\$order\\":\\"-firstJoinedAt\\"/); + assert.match(participationQueries, /\\"\$limit\\":2/); + assert.equal(queries.length, 3, "identity, bounded scan, and one ordered resolvable relation query only"); +} finally { + globalThis.fetch = originalFetch; + for (const [key, value] of [["CODECKS_PROFILE", priorProfile], ["CODECKS_PROFILE_PERSONAL_TOKEN", priorToken], ["CODECKS_ACCOUNT", priorAccount], ["CODECKS_CREDENTIAL_PROVIDER", priorProvider]] as const) { + if (value === undefined) delete process.env[key]; else process.env[key] = value; + } +} + +console.log("PASS: Codecks debug dynamic registration and ordered relation probes passed"); From 8db9823b9c6650d85c3c478a764a790d7aeb9801 Mon Sep 17 00:00:00 2001 From: aefreedman Date: Mon, 28 Sep 2026 22:32:16 -0400 Subject: [PATCH 2/3] Use child-count aggregates in card summaries --- CHANGELOG.md | 1 + README.md | 2 +- .../references/card-operations.md | 2 +- src/codecks-core.ts | 43 +++---- tests/card-get-tool.test.ts | 2 + tests/card-search-preview.test.ts | 110 +++++++++++++++++- 6 files changed, 131 insertions(+), 29 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3e43807..298e365 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ and this project follows semantic versioning for public package releases. - Push resolved Deck filters into paged card searches while retaining defensive Deck and local milestone checks; a Deck-scoped scan budget now counts server-returned Deck rows without broad fallback on permission or query errors. Milestone-only searches remain account-visible scans. - Bound optional resolvable diagnostic relation probes to schema-confirmed ordered has-many relations; unsupported relations fail locally rather than sending invalid pagination. +- Use Codecks `count:childCards` in summary/search/effort-preview reads instead of downloading child-card references solely to calculate a count; preserve full child relations in detail views, multifacet counts output, and unknown versus observed-zero counts. ### Fixed diff --git a/README.md b/README.md index b71de0e..4471f1e 100644 --- a/README.md +++ b/README.md @@ -188,7 +188,7 @@ Use `codecks_card_get_batch` for full details of 1-25 known short-code and/or `s Use `codecks_card_get_formatted` when the agent needs to present human-readable card details to a user. -Use `codecks_card_search` when title/location criteria may match multiple cards and the agent needs disambiguation. Supplying `deck` or `milestone` without `location` infers the corresponding scope instead of running a broad search. Deck and milestone filters can be combined for intersection searches such as cards in the Alpha milestone and Dev deck. If an agent accidentally passes a visible deck name such as `Design Docs` or `Vertical Slice` as `location`, the registration layer treats it as `deck` when no explicit deck/milestone was supplied. Title searches support `*` / `?` glob wildcards and accent/punctuation-insensitive matching. Use `text` with `searchIn: "title_or_content"` (or `content`) for body searches, and `includeDone: false` for open/undone-only searches. Structured search results include planning metadata such as effort, card type, child count, deck/milestone identity, matched fields, update dates, and reusable `cardRef` / `accountSeqRef` values when Codecks returns those fields. Bounded scans report `scannedCards`, `complete`, `scanLimitReached`, request count, queue wait, and elapsed time. `complete` means complete only within token-visible projects, not necessarily the organization; invisible cards are not deleted cards. Account scans are concurrency-bounded: do not launch parallel full-account or high-`scanLimit` searches; prefer one shared-scope bulk preview or narrow sequential searches. Search output defaults to compact mode and caps returned card rows to protect session context; use `outputMode: "counts"` for bulk scope/effort analysis and `outputMode: "detailed"` only when every returned card row is truly needed. No-match searches return successful empty results with search tips instead of tool errors. Deck-scoped paged account-card searches now send the resolved deck ID to Codecks and verify returned deck relations locally; `scanLimit` counts server-returned deck rows, not all account cards. A combined Deck + milestone search sends only the Deck filter and checks the milestone locally; milestone-only searches still scan token-visible account cards. A rejected scoped query fails rather than silently falling back to a broad scan. +Use `codecks_card_search` when title/location criteria may match multiple cards and the agent needs disambiguation. Supplying `deck` or `milestone` without `location` infers the corresponding scope instead of running a broad search. Deck and milestone filters can be combined for intersection searches such as cards in the Alpha milestone and Dev deck. If an agent accidentally passes a visible deck name such as `Design Docs` or `Vertical Slice` as `location`, the registration layer treats it as `deck` when no explicit deck/milestone was supplied. Title searches support `*` / `?` glob wildcards and accent/punctuation-insensitive matching. Use `text` with `searchIn: "title_or_content"` (or `content`) for body searches, and `includeDone: false` for open/undone-only searches. Structured search results include planning metadata such as effort, card type, child count, deck/milestone identity, matched fields, update dates, and reusable `cardRef` / `accountSeqRef` values when Codecks returns those fields. Bounded scans report `scannedCards`, `complete`, `scanLimitReached`, request count, queue wait, and elapsed time. `complete` means complete only within token-visible projects, not necessarily the organization; invisible cards are not deleted cards. Account scans are concurrency-bounded: do not launch parallel full-account or high-`scanLimit` searches; prefer one shared-scope bulk preview or narrow sequential searches. Search output defaults to compact mode and caps returned card rows to protect session context; use `outputMode: "counts"` for bulk scope/effort analysis and `outputMode: "detailed"` only when every returned card row is truly needed. No-match searches return successful empty results with search tips instead of tool errors. Deck-scoped paged account-card searches now send the resolved deck ID to Codecks and verify returned deck relations locally; `scanLimit` counts server-returned deck rows, not all account cards. A combined Deck + milestone search sends only the Deck filter and checks the milestone locally; milestone-only searches still scan token-visible account cards. A rejected scoped query fails rather than silently falling back to a broad scan. Compact search and missing-effort summaries request the numeric child-card count rather than child identities; zero is reported only when observed, and a missing or invalid aggregate remains unknown. `outputMode: "counts"` still calculates all its facets across scanned cards. Full card detail reads continue to fetch child cards. Use `codecks_card_bulk_create` and `codecks_card_bulk_update` for CSV/import-style tracker work after mapping source rows into strict card objects. Both default to dry-run and apply must pass that dry run's `previewFingerprint` as `expectedPreviewFingerprint`; a matching authorization covers the matching apply without another interaction, but ask again when the preview differs or exceeds the approved scope. Unsupported fields are rejected before requests; use `assigneeId` from `codecks_user_lookup`, not `assignee`. After approval, submit one bulk operation: the package paces every physical request at 40 requests per five seconds, so do not manually chunk records or count requests. diff --git a/skills/using-codecks/references/card-operations.md b/skills/using-codecks/references/card-operations.md index 8eae61f..5696fcd 100644 --- a/skills/using-codecks/references/card-operations.md +++ b/skills/using-codecks/references/card-operations.md @@ -24,7 +24,7 @@ Read this reference for card lookup, creation, ordinary updates, lifecycle chang - `codecks_card_search` excludes archived/deleted cards by default; set `includeArchived=true` only when explicitly needed. - Supplying a Deck or milestone without `location` infers that scope. Deck-scoped paged account-card searches push the resolved Deck ID to the server, then defensively verify each result; `scanLimit` counts returned Deck rows. Deck + milestone intersections push only the Deck filter and verify the milestone locally; milestone-only searches still scan token-visible account cards. A rejected scoped query never falls back to a broad scan. - `title` supports partial and glob-style `*`/`?` matching. Use `text` with `searchIn: "title_or_content"` or `"content"` for bodies. -- Compact output is the default. Prefer `outputMode: "counts"` for aggregate analysis and `"detailed"` only when every row is required. +- Compact output is the default. Prefer `outputMode: "counts"` for multifacet analysis and `"detailed"` only when every row is required. Summary child counts use a numeric aggregate without fetching child identities; absent or invalid counts remain unknown rather than zero. Full detail reads retain child-card relations. - No-match results are successful empty searches. Inspect criteria/tips rather than treating them as failures. - For open/undone cards, use `includeDone=false` instead of post-filtering done cards. - Do not launch parallel full-account or high-`scanLimit` searches. Treat `complete=false`, cancellation, timeout, or queue rejection as incomplete evidence, never a definitive empty result. diff --git a/src/codecks-core.ts b/src/codecks-core.ts index 7e5407b..4618fbd 100644 --- a/src/codecks-core.ts +++ b/src/codecks-core.ts @@ -3890,7 +3890,7 @@ const cardSummaryFields = [ const cardPlanningFields = [ ...cardSummaryFields, - { childCards: ["cardId", "accountSeq"] }, + "count:childCards", ]; const cardDetailFields = [ @@ -3968,26 +3968,7 @@ const handCardFields = [ "userId", "isVisible", "sortIndex", - { - card: [ - "cardId", - "accountSeq", - "title", - "status", - "derivedStatus", - "isDoc", - "visibility", - "lastUpdatedAt", - "dueDate", - "effort", - "priority", - "masterTags", - { deck: ["id", "title", "accountSeq"] }, - { milestone: ["id", "name", "accountSeq"] }, - { assignee: ["id", "name", "fullName"] }, - { childCards: ["cardId", "accountSeq"] }, - ], - }, + { card: cardPlanningFields }, { user: ["id", "name", "fullName"] }, ]; @@ -5328,15 +5309,25 @@ const hasOwn = (value: CodecksEntity, key: string): boolean => const getCardChildCountInfo = (card: CodecksEntity): { known: boolean; count: number | null } => { - if (!hasOwn(card, "childCards")) + const aggregate = card["count:childCards"]; + if (typeof aggregate === "number" && Number.isSafeInteger(aggregate) && aggregate >= 0) + { + return { known: true, count: aggregate }; + } + + const relation = getRelation(card, "childCards"); + const children = Array.isArray(relation) ? relation : [relation]; + const isChildReference = (value: unknown): boolean => + (typeof value === "string" && value.trim().length > 0) + || (typeof value === "number" && Number.isFinite(value)) + || (Boolean(value) && typeof value === "object" && !Array.isArray(value) + && ["cardId", "accountSeq", "id"].some((key) => (value as CodecksEntity)[key] !== undefined)); + if (relation === undefined || relation === null || !children.every(isChildReference)) { return { known: false, count: null }; } - return { - known: true, - count: normalizeCollection(getRelation(card, "childCards") as unknown[] | undefined).length, - }; + return { known: true, count: children.length }; }; const getCardChildCount = (card: CodecksEntity): number => diff --git a/tests/card-get-tool.test.ts b/tests/card-get-tool.test.ts index b52e6f1..b0ce269 100644 --- a/tests/card-get-tool.test.ts +++ b/tests/card-get-tool.test.ts @@ -150,6 +150,8 @@ const testDirectShortCodeReturnsStructuredCard = async (tools: ToolModule): Prom const cardsRelation = getAccountRelation(query, "cards"); assert.ok(cardsRelation, `expected direct account card query: ${JSON.stringify(query)}`); assert.match(cardsRelation.key, /accountSeq/); + assert.match(JSON.stringify(cardsRelation.fields), /"childCards"/, "detail retrieval keeps full child relations"); + assert.doesNotMatch(JSON.stringify(cardsRelation.fields), /count:childCards/, "detail retrieval is not replaced with count-only selection"); return jsonResponse({ data: buildDetailPayload() }); }, async () => { const result = await tools.card_get.execute({ cardId: CARD_REF }); diff --git a/tests/card-search-preview.test.ts b/tests/card-search-preview.test.ts index 6545e91..976b807 100644 --- a/tests/card-search-preview.test.ts +++ b/tests/card-search-preview.test.ts @@ -12,7 +12,9 @@ type FetchMockOptions = { rejectCardQueries?: boolean; ignoreDeckFilter?: boolean; denyCardQueries?: boolean; + denyAggregate?: boolean; denyPageAtOffset?: number; + keepChildRelationOnSummary?: boolean; }; const deck = { id: "deck-dev", title: "Dev", accountSeq: 2 }; @@ -51,8 +53,10 @@ const makeCardsPayload = (relationKey: string, cards: MockCard[]) => ({ }, }); +let lastMockResponseSerializedBytes = 0; const installFetchMock = (cards: MockCard[], options: FetchMockOptions = {}) => { const requests: any[] = []; + lastMockResponseSerializedBytes = 0; globalThis.fetch = (async (_url: string | URL | Request, init?: RequestInit) => { const body = JSON.parse(String(init?.body ?? "{}")); requests.push(body); @@ -82,6 +86,9 @@ const installFetchMock = (cards: MockCard[], options: FetchMockOptions = {}) => if (options.denyCardQueries) { return new Response(JSON.stringify({ error: "missing_scope", path: "_root.account.cards", requiredScope: "card:read" }), { status: 403 }); } + if (options.denyAggregate && queryText.includes('count:childCards')) { + return new Response(JSON.stringify({ error: "invalid_aggregate", path: "_root.account.cards", message: "Invalid aggregate selection" }), { status: 400 }); + } const relationKey = getCardsRelationKey(body.query); assert.ok(relationKey, `expected cards relation in ${queryText}`); @@ -95,7 +102,21 @@ const installFetchMock = (cards: MockCard[], options: FetchMockOptions = {}) => const offset = Number(filters.$offset ?? 0); const limit = Number(filters.$limit ?? scopedCards.length); const page = scopedCards.slice(offset, offset + limit); - return new Response(JSON.stringify(makeCardsPayload(relationKey, page)), { + const selection = (body.query._root[0].account[0] as Record)[relationKey] as unknown[]; + const projected = page.map((card) => { + if (!selection.includes("count:childCards")) return card; + const result = { ...card }; + if (!options.keepChildRelationOnSummary) delete result.childCards; + if (Object.prototype.hasOwnProperty.call(card, "count:childCards")) { + if (card["count:childCards"] === undefined) delete result["count:childCards"]; + } else if (Array.isArray(card.childCards)) { + result["count:childCards"] = card.childCards.length; + } + return result; + }); + const responseText = JSON.stringify(makeCardsPayload(relationKey, projected)); + lastMockResponseSerializedBytes += Buffer.byteLength(responseText); + return new Response(responseText, { status: 200, headers: { "content-type": "application/json" }, }); @@ -191,6 +212,8 @@ const cards: MockCard[] = [ assert.equal(payload.data.matches, 5); assert.equal(getCardsFilters(requests[1]).deckId, "deck-dev", "resolved deck is pushed into the paged server filter"); assert.ok(JSON.stringify(requests[1].query).includes('"deck"'), "deck relation remains selected for defensive verification"); + assert.ok(JSON.stringify(requests[1].query).includes('"count:childCards"'), "paged summaries request only the child-count aggregate"); + assert.ok(!JSON.stringify(requests[1].query).includes('"childCards"'), "paged summaries do not request child references"); const first = payload.data.cards[0]; assert.equal(first.shortCode, "$14j"); @@ -205,6 +228,79 @@ const cards: MockCard[] = [ assert.equal(first.lastUpdatedAt, "2026-05-11T00:00:00.000Z"); } +{ + const manyChildren = { + ...cards[2], cardId: "synthetic-many-children", accountSeq: 200, + childCards: Array.from({ length: 250 }, (_, index) => `synthetic-child-${index}`), + }; + const requests = installFetchMock([manyChildren, cards[0]]); + const result = parseStructuredJson(String(await core.card_search.execute({ outputMode: "counts", format: "json" }))); + assert.equal(result.ok, true); + assert.equal(requests.length, 1, "aggregate and former child-relation summary each require one page request, no per-card preflight"); + assert.equal(result.data.matches, 2); + assert.equal(result.data.facets.status.not_started, 2, "counts output retains status facets rather than reducing to one aggregate total"); + assert.equal(result.data.sampleCards[0].childCount, 250); + assert.equal(result.data.sampleCards[0].childCountKnown, true); + assert.equal(result.data.sampleCards[1].childCount, 0); + assert.equal(result.data.sampleCards[1].childCountKnown, true); + assert.ok(JSON.stringify(requests[0].query).includes('"count:childCards"')); + assert.ok(!JSON.stringify(requests[0].query).includes('"childCards"')); + const oldNormalizedBytes = Buffer.byteLength(JSON.stringify(makeCardsPayload(getCardsRelationKey(requests[0].query)!, [manyChildren, cards[0]]))); + assert.ok(lastMockResponseSerializedBytes < oldNormalizedBytes, `synthetic normalized JSON: aggregate ${lastMockResponseSerializedBytes} bytes < old child refs ${oldNormalizedBytes} bytes; same one request`); + console.log(`Synthetic high-child fixture: one request each; normalized JSON aggregate ${lastMockResponseSerializedBytes} bytes vs previous child refs ${oldNormalizedBytes} bytes (not wire/compressed bytes).`); +} + +{ + const fixture = [ + { ...cards[0], cardId: "synthetic-unknown-count", accountSeq: 210, childCards: undefined, "count:childCards": undefined }, + { ...cards[0], cardId: "synthetic-invalid-count", accountSeq: 211, childCards: undefined, "count:childCards": "0" }, + { ...cards[0], cardId: "synthetic-negative-count", accountSeq: 212, childCards: undefined, "count:childCards": -1 }, + { ...cards[0], cardId: "synthetic-observed-zero", accountSeq: 213, childCards: [], "count:childCards": 0 }, + { ...cards[0], cardId: "synthetic-relation-fallback", accountSeq: 214, childCards: ["synthetic-child"], "count:childCards": "invalid" }, + ]; + installFetchMock(fixture); + const result = parseStructuredJson(String(await core.card_search.execute({ format: "json" }))); + assert.equal(result.ok, true); + assert.deepEqual(result.data.cards.map((card: any) => [card.childCountKnown, card.childCount]), [ + [false, null], [false, null], [false, null], [true, 0], [false, null], + ], "missing/invalid/forbidden-like aggregate values must not become known zero"); + installFetchMock(fixture, { keepChildRelationOnSummary: true }); + const withRelation = parseStructuredJson(String(await core.card_search.execute({ format: "json" }))); + assert.equal(withRelation.data.cards[3].childCountKnown, true, "present empty relation is still a known zero"); + assert.equal(withRelation.data.cards[4].childCount, 1, "a valid present child relation may backfill an invalid aggregate without another request"); +} + +{ + const requests: any[] = []; + globalThis.fetch = (async (_url, init) => { + const body = JSON.parse(String(init?.body ?? "{}")); + requests.push(body); + const root = body.query?._root?.[0]; + if (root?.loggedInUser) { + return new Response(JSON.stringify({ data: { _root: { loggedInUser: "synthetic-user" }, user: { "synthetic-user": { id: "synthetic-user" } } } }), { status: 200 }); + } + const relationKey = Object.keys(root?.account?.[0] ?? {})[0]; + assert.match(relationKey, /^(handCards|queueEntries)\(/); + const relationFields = root.account[0][relationKey]; + assert.ok(JSON.stringify(relationFields).includes('"count:childCards"'), "Hand/bookmark summaries request child aggregates"); + assert.ok(!JSON.stringify(relationFields).includes('"childCards"'), "Hand/bookmark summaries omit child identities"); + const isHand = relationKey.startsWith("queueEntries("); + const entity = isHand ? "queueEntry" : "handCard"; + return new Response(JSON.stringify({ data: { + _root: { account: { [relationKey]: ["row-1"] } }, + [entity]: { "row-1": { userId: "synthetic-user", sortIndex: 0, card: "synthetic-card" } }, + card: { "synthetic-card": { ...cards[0], cardId: "synthetic-card", "count:childCards": 7, childCards: undefined } }, + } }), { status: 200 }); + }) as typeof fetch; + for (const location of ["hand", "bookmarks"] as const) { + const result = parseStructuredJson(String(await core.card_search.execute({ location, format: "json" }))); + assert.equal(result.ok, true, `${location} summary should hydrate numeric count`); + assert.equal(result.data.cards[0].childCount, 7); + assert.equal(result.data.cards[0].childCountKnown, true); + } + assert.equal(requests.length, 4, "each personal summary needs only its existing identity + relation read"); +} + { const requests = installFetchMock(cards); const text = String(await core.card_search.execute({ milestone: "Alpha", format: "json" })); @@ -378,6 +474,18 @@ const cards: MockCard[] = [ assert.equal(denied.error.apiPath, "_root.account.cards"); assert.equal(requests.length, 2, "a rejected scoped query never falls back to an account-wide query"); assert.equal(getCardsFilters(requests[1]).deckId, "deck-dev"); + assert.ok(JSON.stringify(requests[1].query).includes('"count:childCards"'), "aggregate selection errors are not retried without the aggregate"); +} + +{ + const requests = installFetchMock(cards, { denyAggregate: true }); + const rejected = parseStructuredJson(String(await core.card_search.execute({ deck: "Dev", format: "json" }))); + assert.equal(rejected.ok, false); + assert.equal(rejected.error.apiCode, "invalid_aggregate"); + assert.equal(rejected.error.apiPath, "_root.account.cards"); + assert.equal(rejected.error.httpStatus, 400); + assert.equal(requests.length, 2, "invalid aggregate never retries without aggregate or without deck filter"); + assert.equal(getCardsFilters(requests[1]).deckId, "deck-dev"); } { From de5b2938eeb125e36196a3fd9ec05dfc854ddc7f Mon Sep 17 00:00:00 2001 From: aefreedman Date: Mon, 28 Sep 2026 22:50:50 -0400 Subject: [PATCH 3/3] chore: prepare pi-codecks 0.14.1 release --- CHANGELOG.md | 10 +++++----- package-lock.json | 4 ++-- package.json | 2 +- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 298e365..885c338 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,17 +5,17 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project follows semantic versioning for public package releases. -## Unreleased +## [0.14.1] - 2026-09-28 ### Changed -- Push resolved Deck filters into paged card searches while retaining defensive Deck and local milestone checks; a Deck-scoped scan budget now counts server-returned Deck rows without broad fallback on permission or query errors. Milestone-only searches remain account-visible scans. -- Bound optional resolvable diagnostic relation probes to schema-confirmed ordered has-many relations; unsupported relations fail locally rather than sending invalid pagination. -- Use Codecks `count:childCards` in summary/search/effort-preview reads instead of downloading child-card references solely to calculate a count; preserve full child relations in detail views, multifacet counts output, and unknown versus observed-zero counts. +- Filter paged card searches by resolved Deck ID at the server while still checking Deck and milestone scopes locally. Deck-scoped scan limits now count server-returned Deck cards; milestone-only searches still scan token-visible account cards. +- Request `count:childCards` for card-search and effort-preview summaries instead of fetching child references just to count them. Full detail views still fetch children, and count-only search output retains its facets. Missing or invalid counts remain unknown rather than observed zero; previously ambiguous missing child data could appear as zero. ### Fixed -- Preserve structured permission/query diagnostics and bounded scan progress when a scoped card page fails, without including earlier card rows in errors. +- Preserve bounded permission/query diagnostics and scan progress on rejected scoped card pages without retrying a broader query or exposing earlier rows. +- Order optional diagnostic relation probes with supported fields and reject unsupported probes locally instead of sending invalid pagination. ## [0.14.0] - 2026-09-28 diff --git a/package-lock.json b/package-lock.json index 47e5ec0..869bcfa 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@aefree/pi-codecks", - "version": "0.14.0", + "version": "0.14.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@aefree/pi-codecks", - "version": "0.14.0", + "version": "0.14.1", "license": "MIT", "dependencies": { "typebox": "1.3.8" diff --git a/package.json b/package.json index f6d283e..7695e9a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@aefree/pi-codecks", - "version": "0.14.0", + "version": "0.14.1", "description": "Pi tools, skills, and prompts for safe Codecks project-management workflows.", "type": "module", "license": "MIT",