iit-mcp-autotask 0.35.0 → 0.36.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -8,10 +8,10 @@ context into one call, a generic read layer for the long tail, one result envelo
8
8
  packaged playbooks, strong security boundaries, and predictable per-tenant in-process state (pooled
9
9
  client + SDK-owned TTL caches).
10
10
 
11
- > **Package status (2026-10-24):** the runtime dependency `node-autotask` is published to public npm
12
- > at `0.25.0`; the committed `pnpm-lock.yaml` pins that
11
+ > **Package status (2026-10-29):** the runtime dependency `node-autotask` is published to public npm
12
+ > at `0.37.0`; the committed `pnpm-lock.yaml` pins that
13
13
  > release with its integrity hash, so installs resolve deterministically. The dependency is always npm
14
- > (node-autotask pinned exactly at `0.25.0`) — never a `file:`, `git:`, `link:` or workspace reference
14
+ > (node-autotask pinned exactly at `0.37.0`) — never a `file:`, `git:`, `link:` or workspace reference
15
15
  > to a local checkout.
16
16
 
17
17
  > **0.6.0 is a breaking release** (unreleased): tool names, result shapes and configuration all
@@ -21,7 +21,7 @@ client + SDK-owned TTL caches).
21
21
 
22
22
  The **core** toolset (12 tools) is always served; `AUTOTASK_TOOLSETS` adds `service`, `records` and
23
23
  `finance` (or `all`, 19 tools); the operator profile adds two. The whole tool surface (21 tools) is
24
- listed below. Every other operation in the SDK's 1,216-operation registry stays
24
+ listed below. Every other operation in the SDK's 1,281-operation registry stays
25
25
  reachable through the generic layer: `autotask_catalog` finds the key, `autotask_describe` serves
26
26
  its contract, `autotask_read` runs it.
27
27
 
@@ -763,7 +763,7 @@ independent review with its resolutions, [`docs/reviews/0.6.0.md`](docs/reviews/
763
763
 
764
764
  ## SDK dependency
765
765
 
766
- `iit-mcp-autotask` depends on **`node-autotask` from npm at exactly `0.25.0`** (an exact pin —
766
+ `iit-mcp-autotask` depends on **`node-autotask` from npm at exactly `0.37.0`** (an exact pin —
767
767
  no range operator; subpaths `.` / `/capabilities` / `/operations` / `/resources` / `/types` /
768
768
  `/pool`) — the only **vendor** dependency; the other two runtime dependencies are
769
769
  `@modelcontextprotocol/server` (the MCP framework) and `zod` (schema validation). It never
@@ -773,7 +773,7 @@ user — the published tarball ships no lockfile) must resolve to the release th
773
773
  reviewed against, or the plan-hash boot check refuses to start.
774
774
 
775
775
  **Publishing `node-autotask` is the maintainer's action, done manually.** It is published on the
776
- public registry at `0.25.0` (published 2026-10-23); the
776
+ public registry at `0.37.0` (published 2026-10-28 UTC); the
777
777
  exact pin and the committed `pnpm-lock.yaml` (with `pnpm-workspace.yaml`) pin the real published
778
778
  tarball with its integrity hash, so installs resolve to exactly that release — the lockfile
779
779
  freezes the pnpm/git/Docker paths, the pin freezes the npm/npx paths. The docs are written against this
@@ -867,9 +867,9 @@ physical assets (Autotask models them as Configuration Items — covered).
867
867
 
868
868
  | # | Item | Status |
869
869
  |---|------|--------|
870
- | B1 | **Project phase & task lists** (W7 sub-capability): no `project_tasks`/`project_phases` operations in the SDK's 1,216-op registry. W7 ships with project status + context; phase/task lists excluded and documented. Classification (SDK gap vs vendor API-version limit) is decided by recon's spec-side check (A2). | blocked — documented |
870
+ | B1 | **Project phase & task lists** (W7 sub-capability): no `project_tasks`/`project_phases` operations in the SDK's 1,281-op registry. W7 ships with project status + context; phase/task lists excluded and documented. Classification (SDK gap vs vendor API-version limit) is decided by recon's spec-side check (A2). | blocked — documented |
871
871
  | B2 | **Time-entry writes on ww29/australia-nz for API-only users**: vendor 500 (live-verified). Time-entry creation works where the API user is a real resource, through the operator profile (`log-time-entry`); on ww29 the write is classified `failed` (a 500 with a vendor `errors[]` body), never `unknown`. | zone limitation |
872
- | B3 | **`time_entries.getPosted`/`getUnposted`: the zone's TimeEntry entity has no `postedDate`**, so SDK 0.31 and earlier 500ed (and `postedWithin`/`dateWorkedWindow` silently counted every entry unposted). SDK 0.32.0 derives posted state from a `billing_items.timeEntryID` join and returns `{ items, hasMore, window, approvedCount, unapprovedCount }` (optional date-only `dateFrom`/`dateTo`; no dates reads only the first 500 entries). Needs BillingItems read permission (one extra request per call); `approvedCount`/`unapprovedCount` are not posted state. Reachable via the generic layer. | resolved in SDK 0.32.0 (not re-verified live) |
872
+ | B3 | **`time_entries.getPosted`/`getUnposted`: the zone's TimeEntry entity has no `postedDate`**, so SDK 0.31 and earlier 500ed (and `postedWithin`/`dateWorkedWindow` silently counted every entry unposted). SDK 0.32.0 derives posted state from a `billing_items.timeEntryID` join and returns `{ items, hasMore, window, approvedCount, unapprovedCount }` (optional date-only `dateFrom`/`dateTo`; no dates reads only the first 500 entries). Needs BillingItems read permission (one extra request per call); `approvedCount`/`unapprovedCount` are not posted state. SDK 0.37.0 adds `cursor`/`nextCursor` (page past 500 entries), `unconfirmed` (entries whose posted state is unknown, kept out of both counts) and `countOnly: true` on the four posted-state ops. Reachable via the generic layer. | resolved in SDK 0.32.0 (not re-verified live) |
873
873
  | B4 | **CI create on tenants without products** (ww29 product wall: `products.count = 0`): `configuration_items.create` requires an existing `productID`. No dedicated CI-create in v1; the wall is documented on the generic side. | zone limitation |
874
874
  | B5 | **Blind count/query surfaces on ww29** (companies not owned by the caller; tickets): count/query return 0/empty even when data exists. Not fixable client-side; the surface carries the typed blind-surface sentinel — `get(id)`/full-profile are the authorities. | zone limitation |
875
875
  | B6 | **`node-autotask` npm publish**: the Phase-C install gate re-checks the registry; if still 404 the build pauses and escalates (SCOPING-resolved: the user publishes — no git/file dependency fallback). | open — user action |
package/capabilities.json CHANGED
@@ -69,7 +69,7 @@
69
69
  },
70
70
  {
71
71
  "id": "autotask.platform.catalog",
72
- "description": "Find the exact operation key for anything without a dedicated tool: ranked search (q) over the 1,216 registry operations, with effect and whether this deployment serves it. No vendor call.",
72
+ "description": "Find the exact operation key for anything without a dedicated tool: ranked search (q) over the 1,281 registry operations, with effect and whether this deployment serves it. No vendor call.",
73
73
  "mcpPrimitive": "tool",
74
74
  "toolName": "autotask_catalog",
75
75
  "type": "read",
@@ -395,7 +395,7 @@
395
395
  "autotask_describe",
396
396
  "autotask_read"
397
397
  ],
398
- "backingRegistryOperations": 1280,
398
+ "backingRegistryOperations": 1281,
399
399
  "note": "autotask_catalog finds the exact operation key (ranked search), autotask_describe serves its contract, autotask_read runs any effect=read key. Writes go through the operator profile (autotask_write then autotask_apply); authenticate.* is never served."
400
400
  },
401
401
  "schemas": {}
@@ -65,10 +65,10 @@ export declare const UNTRUSTED_NOTE = "Externally-sourced free text in the resul
65
65
  /** The record's plan-derived untrusted field list (the registry stamp). */
66
66
  export declare function untrustedFieldsOf(rec: CapabilityRecord): string[];
67
67
  /**
68
- * The resources that carry user-defined fields (#183 S8), derived from the registry with zero wire:
69
- * the plan's `udfCapable` entities (the ones whose SDK resource class normalises `userDefinedFields`)
70
- * are exactly the resources whose records stamp `UDF1` in `metadata.untrustedFields`. Not the
71
- * `getUdfInfo` set (every vendor entity has that metadata read, UDFs or not). A test pins the exact set.
68
+ * The resources that carry user-defined fields (#183 S8), read from the registry with zero wire:
69
+ * each record's published `udfCapable` flag (node-autotask 0.37.0 #254; the plan's entities whose SDK
70
+ * resource class normalises `userDefinedFields`). Not the `getUdfInfo` set (every vendor entity has
71
+ * that metadata read, UDFs or not). A test pins the exact set.
72
72
  */
73
73
  export declare const UDF_CAPABLE: ReadonlySet<string>;
74
74
  /** The per-tool description budget (A0 slimming: every served description <= 250 chars). */
@@ -109,7 +109,7 @@ export declare function cacheOptionsForPreset(preset: 'on' | 'off', fieldCacheTt
109
109
  * fails the boot (or the CI test) instead of quietly shipping a tool surface that no longer
110
110
  * describes the SDK behind it.
111
111
  */
112
- export declare const BUILT_AGAINST_PLAN_HASH = "08cf890fa1b2b50574638a1364993fced7e83a5dfd4f630763d80cdc01d5a5f1";
112
+ export declare const BUILT_AGAINST_PLAN_HASH = "5cd6c6679384651e7f67a0135915bcc031ef542640d9ebb6992b729ca42efe37";
113
113
  /**
114
114
  * Fail fast when the installed SDK's plan has moved out from under this surface.
115
115
  * Called at boot (main.ts) and safe to call repeatedly (pure comparison).
@@ -4,7 +4,7 @@
4
4
  * Two sources, both shipped inside the `node-autotask` package:
5
5
  * - `MCP_TOOL_CATALOG.json` — the machine catalog: one row per registry operation (projected
6
6
  * tool, effect, dry-run/confirm, required args, tier, reach) and the plan hash;
7
- * - the compiled capability registry (`node-autotask/capabilities`) — the 1,216-operation
7
+ * - the compiled capability registry (`node-autotask/capabilities`) — the 1,281-operation
8
8
  * records the SDK's own invoke governor resolves against.
9
9
  *
10
10
  * The JSON file is NOT behind the package's `exports` map, so it is read from the installed
@@ -102,12 +102,12 @@ export function untrustedFieldsOf(rec) {
102
102
  return Array.isArray(list) ? list : [];
103
103
  }
104
104
  /**
105
- * The resources that carry user-defined fields (#183 S8), derived from the registry with zero wire:
106
- * the plan's `udfCapable` entities (the ones whose SDK resource class normalises `userDefinedFields`)
107
- * are exactly the resources whose records stamp `UDF1` in `metadata.untrustedFields`. Not the
108
- * `getUdfInfo` set (every vendor entity has that metadata read, UDFs or not). A test pins the exact set.
105
+ * The resources that carry user-defined fields (#183 S8), read from the registry with zero wire:
106
+ * each record's published `udfCapable` flag (node-autotask 0.37.0 #254; the plan's entities whose SDK
107
+ * resource class normalises `userDefinedFields`). Not the `getUdfInfo` set (every vendor entity has
108
+ * that metadata read, UDFs or not). A test pins the exact set.
109
109
  */
110
- export const UDF_CAPABLE = new Set(RECORDS.filter((r) => untrustedFieldsOf(r).includes('UDF1')).map((r) => r.resource));
110
+ export const UDF_CAPABLE = new Set(RECORDS.filter((r) => r.udfCapable).map((r) => r.resource));
111
111
  /** The per-tool description budget (A0 slimming: every served description <= 250 chars). */
112
112
  export const MAX_DESCRIPTION_CHARS = 250;
113
113
  /**
@@ -117,7 +117,7 @@ export const MAX_DESCRIPTION_CHARS = 250;
117
117
  */
118
118
  export const TOOL_DESCRIPTIONS = {
119
119
  autotask_guide: 'Step-by-step playbooks for multi-step Autotask tasks. No args: the index; {skill} returns one playbook, {skill, section} one section. Static text, no vendor call. Use it first when a task needs several tools.',
120
- autotask_catalog: 'Find the exact operation key for anything without a dedicated tool: ranked search (q) over the 1,216 registry operations, with effect and whether this deployment serves it. No vendor call.',
120
+ autotask_catalog: 'Find the exact operation key for anything without a dedicated tool: ranked search (q) over the 1,281 registry operations, with effect and whether this deployment serves it. No vendor call.',
121
121
  autotask_describe: 'One operation\'s (registry key or tool name) exact input/output contract, examples, errors, policy verdict and playbooks (operation), or one resource\'s relationship map: parent, children, lookup keys (resource). No vendor call.',
122
122
  autotask_read: 'Run reads by exact registry key, or up to 10 in one call: unbilled work, profitability and margin, SLA health. *.query: 50 rows; scope by company/contract ids (unscoped = tenant walk); roll-ups: autotask_aggregate; slim: includeFields, noteChars.',
123
123
  autotask_resolve: 'Find the record behind an identifier: id or name, ticket number, CI serial, contact email (by), staff by email, user name or name (resource resources, include_api_users); one resource or a sweep. Exact by default; contains: companies, tickets, sweep.',
@@ -173,7 +173,7 @@ export function cacheOptionsForPreset(preset, fieldCacheTtlMs) {
173
173
  * fails the boot (or the CI test) instead of quietly shipping a tool surface that no longer
174
174
  * describes the SDK behind it.
175
175
  */
176
- export const BUILT_AGAINST_PLAN_HASH = '08cf890fa1b2b50574638a1364993fced7e83a5dfd4f630763d80cdc01d5a5f1';
176
+ export const BUILT_AGAINST_PLAN_HASH = '5cd6c6679384651e7f67a0135915bcc031ef542640d9ebb6992b729ca42efe37';
177
177
  /**
178
178
  * Fail fast when the installed SDK's plan has moved out from under this surface.
179
179
  * Called at boot (main.ts) and safe to call repeatedly (pure comparison).
@@ -56,6 +56,13 @@ export declare function abortedAnswer(corr: string): ToolReturn;
56
56
  * in a 4xx body; the error wrap value-scrubs them before the envelope crosses.
57
57
  */
58
58
  export declare function requestSecrets(ctx: ToolContext): string[];
59
+ /**
60
+ * A vendor TimeEntries continuation URL (#224; mirrors the SDK's cursorToPath + entryCursorPath, tighter):
61
+ * printable ASCII with no whitespace, absolute and already in canonical form, on this request's own Autotask origin, no userinfo, no
62
+ * fragment, exactly the continuation path, and only `paging` / `search` query parameters with encoded
63
+ * values. Anything else is not granted the cursor exemption (it is served as wrapped, capped vendor text).
64
+ */
65
+ export declare function isEntryCursor(value: string, origin: string | undefined): boolean;
59
66
  /**
60
67
  * Per-operation post-processing for results read through `autotask_read`: the helper operations
61
68
  * that used to have their own tools carry vendor text beyond the registry stamp, and this is where
@@ -15,8 +15,9 @@ import { AutotaskConfigError } from 'node-autotask';
15
15
  import { stripUntrustedDeep } from 'node-autotask/untrusted';
16
16
  import { assertFilterShape, QUERY_LIMIT, QUERY_RESOURCES } from './schemas.js';
17
17
  import { recordById, untrustedFieldsOf } from './catalog-data.js';
18
+ import { AUTOTASK_BASE_PATH } from '../base-hosts.js';
18
19
  import { releaseClientOnError } from './context.js';
19
- import { abortedResult, closedArgsError, crossResourceUntrusted, errorContent, isFileOperation, markFilePayload, markResult, outcome, refusalContent } from './results.js';
20
+ import { abortedResult, closedArgsError, crossResourceUntrusted, errorContent, isFileOperation, markFilePayload, markResult, markServerField, outcome, refusalContent } from './results.js';
20
21
  import { lastFullCalendarMonth, lastFullCalendarMonthLabel } from './profitability-window.js';
21
22
  import { assertOperationPermitted } from '../policy.js';
22
23
  import { ACK_KEY, guardUnscopedWalk, ScopeRequiredError } from './scope-guard.js';
@@ -83,7 +84,7 @@ async function frame(ctx, input, rawArgs, extra, work) {
83
84
  if (!parsed.success) {
84
85
  const issue = parsed.error.issues[0];
85
86
  const field = issue === undefined ? undefined : issue.path.map(String).join('.');
86
- return errorContent(new InputInvalidError(`invalid input: ${parsed.error.issues.map((i) => `${i.path.map(String).join('.') || '(input)'}: ${i.message}`).join('; ')}`, field === '' ? undefined : field), ctx.correlationId);
87
+ return errorContent(new InputInvalidError(`invalid input: ${input.hint?.(raw) ?? ''}${parsed.error.issues.map((i) => `${i.path.map(String).join('.') || '(input)'}: ${i.message}`).join('; ')}`, field === '' ? undefined : field), ctx.correlationId);
87
88
  }
88
89
  if (ctx.refusal !== undefined)
89
90
  return refusalContent(ctx.refusal.kind, ctx.correlationId, ctx.refusal.anomalies ?? []);
@@ -143,6 +144,35 @@ export function requestSecrets(ctx) {
143
144
  }
144
145
  return out;
145
146
  }
147
+ /** The time_entries posted-state reads whose `nextCursor` is a pass-back pagination cursor (SDK 0.37.0 #242). */
148
+ const CURSOR_OPS = new Set(['time_entries.getPosted', 'time_entries.getUnposted', 'time_entries.postedWithin', 'time_entries.dateWorkedWindow']);
149
+ /** The continuation path a TimeEntries cursor names, under the fixed base path (compared case-insensitively). */
150
+ const ENTRY_CURSOR_PATH = `${AUTOTASK_BASE_PATH}/V1.0/TimeEntries/query/next`.toLowerCase();
151
+ /** The query a vendor continuation URL carries: `paging` and `search`, URL-encoded values only. */
152
+ const ENTRY_CURSOR_PARAM = /^(?:paging|search)=[A-Za-z0-9%._~+-]+$/;
153
+ /**
154
+ * A vendor TimeEntries continuation URL (#224; mirrors the SDK's cursorToPath + entryCursorPath, tighter):
155
+ * printable ASCII with no whitespace, absolute and already in canonical form, on this request's own Autotask origin, no userinfo, no
156
+ * fragment, exactly the continuation path, and only `paging` / `search` query parameters with encoded
157
+ * values. Anything else is not granted the cursor exemption (it is served as wrapped, capped vendor text).
158
+ */
159
+ export function isEntryCursor(value, origin) {
160
+ if (origin === undefined || !/^[\x21-\x7e]+$/.test(value) || !value.startsWith(`${origin}/`))
161
+ return false;
162
+ let u;
163
+ try {
164
+ u = new URL(value);
165
+ }
166
+ catch {
167
+ return false;
168
+ }
169
+ if (u.href !== value || u.origin !== origin || u.username !== '' || u.password !== '' || u.hash !== '' || value.includes('#'))
170
+ return false;
171
+ if (u.pathname.toLowerCase() !== ENTRY_CURSOR_PATH)
172
+ return false;
173
+ const query = u.search.replace(/^\?/, '');
174
+ return query === '' || query.split('&').every((p) => ENTRY_CURSOR_PARAM.test(p));
175
+ }
146
176
  /** The reads behind the profitability result's customer and part containers (their untrusted stamps). */
147
177
  const PROFITABILITY_PART_OPS = [
148
178
  'contracts.profitability',
@@ -179,7 +209,30 @@ const PROFITABILITY_UNTRUSTED = [
179
209
  */
180
210
  export const READ_POSTPROCESS = {
181
211
  'contracts.profitability': (result) => markResult(result, PROFITABILITY_UNTRUSTED),
212
+ 'operations.revenueRollup': markRevenueRollup,
182
213
  };
214
+ /**
215
+ * `operations.revenueRollup` (SDK 0.37.0): the helper rows carry no `metadata.untrustedFields`. Vendor text
216
+ * sits in `rows[].keys.<dimension>` (`name`, `division`, `label`) and in the values of
217
+ * `coverage.contractTypeMap` / `contractCategoryMap` (id -> picklist label). The cross-resource list
218
+ * covers `name` and `label` at every depth; `division` (the LOB level-1 name) is added. ponytail: the
219
+ * `coverage.excluded.otherContract*` maps are keyed by label, and a key cannot be wrapped (it is still
220
+ * cleaned and capped by the output sanitiser).
221
+ */
222
+ const ROLLUP_UNTRUSTED = [...crossResourceUntrusted(), 'division'];
223
+ function markRevenueRollup(result) {
224
+ const out = markResult(result, ROLLUP_UNTRUSTED);
225
+ const cov = out?.coverage;
226
+ for (const key of ['contractTypeMap', 'contractCategoryMap']) {
227
+ const map = cov?.[key];
228
+ if (map === null || typeof map !== 'object' || Array.isArray(map))
229
+ continue;
230
+ for (const [id, label] of Object.entries(map))
231
+ if (typeof label === 'string')
232
+ markServerField(map, id, 'wrap');
233
+ }
234
+ return out;
235
+ }
183
236
  /**
184
237
  * Server-built annotations added to a served result AFTER the untrusted marking (issue #102). Each
185
238
  * `operations.fieldCatalog` resource row says which `autotask_query` operation serves it (or null:
@@ -295,6 +348,10 @@ export async function readOneItem(ctx, operation, args, signal, opts) {
295
348
  }
296
349
  const nameChecked = operation.endsWith('.query') || operation.endsWith('.count');
297
350
  const result = await ctx.ops.invokeRead(ctx.client, operation, cleanArgs, opts, signal).catch((err) => {
351
+ // #224: a refused cursor the caller passed is the caller's input; a refused vendor continuation is not
352
+ if (CURSOR_OPS.has(operation) && err?.code === 'RESPONSE_REJECTED' && cleanArgs.some((a) => a !== null && typeof a === 'object' && typeof a.cursor === 'string')) {
353
+ Object.assign(err, { callerInput: true });
354
+ }
298
355
  throw nameChecked ? rewriteFieldNameRefusal(err, operation, operation.slice(0, operation.lastIndexOf('.')), 'read') : err;
299
356
  });
300
357
  if (result === undefined)
@@ -312,6 +369,11 @@ export async function readOneItem(ctx, operation, args, signal, opts) {
312
369
  const projected = result !== null && typeof result === 'object' ? markResult(result, crossResourceUntrusted()) : result;
313
370
  if (isFileOperation(operation))
314
371
  markFilePayload(projected);
372
+ // #224: the posted-state reads' nextCursor goes back verbatim as opts.cursor; the 500 cap would cut it
373
+ const cursor = CURSOR_OPS.has(operation) && projected !== null && typeof projected === 'object' ? projected.nextCursor : undefined;
374
+ // only a TimeEntries continuation URL is a cursor; anything else is vendor text (500 cap, wrapped)
375
+ if (typeof cursor === 'string')
376
+ markServerField(projected, 'nextCursor', isEntryCursor(cursor, ctx.origin) ? 'cursor' : 'wrap');
315
377
  if (operation === 'operations.fieldCatalog')
316
378
  annotateCatalogRows(projected);
317
379
  return { result: projected, ...(note !== undefined ? { note } : {}), ...(scopeNote !== undefined ? { scopeNote } : {}) };
@@ -26,8 +26,8 @@ export declare const FIELD_INFO_EXPENSIVE = "Expensive: it returns the whole fie
26
26
  * read paths (#183 S8, AC13): `autotask_query` (`via: 'query'`: its argument is include_fields, and the escape is
27
27
  * an autotask_read call) and `autotask_read` of a `*.query` / `*.count` key (`via: 'read'`: the bag keeps
28
28
  * includeFields and fieldCheck). The SDK message separates the causes, so the remedy follows the cause: a FILTER
29
- * refusal names the UDF remedy (udf:true, max 1 per query, plus the include:["udf"], labels:true definitions read)
30
- * and says the fieldCheck:false escape is for genuine vendor field names, never a UDF name; an includeFields
29
+ * refusal on a UDF-capable resource names the UDF remedy (udf:true, max 1 per query, plus the include:["udf"], labels:true definitions read)
30
+ * and says the fieldCheck:false escape is for genuine vendor field names, never a UDF name (any other resource: the vendor-name remedy only, no UDF text); an includeFields
31
31
  * refusal carries no UDF advice (whether the vendor accepts a UDF name in IncludeFields is unverified) and keeps
32
32
  * the #76 escape. Every suggestedAction stays within the 500-character cap for every query resource (tests).
33
33
  */
@@ -1,4 +1,5 @@
1
1
  import { AutotaskConfigError } from 'node-autotask';
2
+ import { UDF_CAPABLE } from './catalog-data.js';
2
3
  /**
3
4
  * tools/field-pointer.ts — the one place the "how do I list this resource's field names" call is
4
5
  * written (issues #102, #98). The bare `operations.fieldCatalog` serves the five workflow resources
@@ -33,8 +34,8 @@ export const FIELD_INFO_EXPENSIVE = 'Expensive: it returns the whole field table
33
34
  * read paths (#183 S8, AC13): `autotask_query` (`via: 'query'`: its argument is include_fields, and the escape is
34
35
  * an autotask_read call) and `autotask_read` of a `*.query` / `*.count` key (`via: 'read'`: the bag keeps
35
36
  * includeFields and fieldCheck). The SDK message separates the causes, so the remedy follows the cause: a FILTER
36
- * refusal names the UDF remedy (udf:true, max 1 per query, plus the include:["udf"], labels:true definitions read)
37
- * and says the fieldCheck:false escape is for genuine vendor field names, never a UDF name; an includeFields
37
+ * refusal on a UDF-capable resource names the UDF remedy (udf:true, max 1 per query, plus the include:["udf"], labels:true definitions read)
38
+ * and says the fieldCheck:false escape is for genuine vendor field names, never a UDF name (any other resource: the vendor-name remedy only, no UDF text); an includeFields
38
39
  * refusal carries no UDF advice (whether the vendor accepts a UDF name in IncludeFields is unverified) and keeps
39
40
  * the #76 escape. Every suggestedAction stays within the 500-character cap for every query resource (tests).
40
41
  */
@@ -42,9 +43,12 @@ export function rewriteFieldNameRefusal(err, operation, resource, via) {
42
43
  if (!(err instanceof AutotaskConfigError) || !err.message.includes('unknown field name'))
43
44
  return err;
44
45
  const escape = via === 'query' ? `autotask_read {operation:"${operation}", args:[{..., fieldCheck:false}]}` : 'fieldCheck:false in this args bag';
45
- const suggestedAction = err.message.includes('filter condition(s) carry unknown field name')
46
+ // a resource outside UDF_CAPABLE has no UDFs (describe says 'not udf-capable'): its filter refusal serves only the vendor-name remedy
47
+ const suggestedAction = err.message.includes('filter condition(s) carry unknown field name') && UDF_CAPABLE.has(resource)
46
48
  ? `Use exact vendor field names: ${scopedCatalogCall(resource)}. A user-defined (tenant) field: filter on it with udf:true (max 1 per query); its names and picklist ids: ${udfCatalogCall(resource)}. ${escape} is for a genuine vendor name only, never a UDF name.`
47
- : `Use exact vendor field names: ${scopedCatalogCall(resource)}. To skip the name check use ${escape}.`;
49
+ : err.message.includes('filter condition(s) carry unknown field name')
50
+ ? `Use exact vendor field names: ${scopedCatalogCall(resource)}. To skip the name check for a genuine vendor name use ${escape}.`
51
+ : `Use exact vendor field names: ${scopedCatalogCall(resource)}. To skip the name check use ${escape}.`;
48
52
  const stripped = err.message.replace(/\s*\(src\/field-sets\.ts\)/g, '');
49
53
  return new AutotaskConfigError(via === 'query' ? stripped.replace(/includeFields/g, 'include_fields') : stripped, { operation, suggestedAction });
50
54
  }
@@ -10,6 +10,12 @@ export declare function buildCatalog(ctx: ToolContext): BuiltTool;
10
10
  export declare function paramNames(operation: string, rec: unknown): Record<string, unknown> | undefined;
11
11
  /** `autotask_describe`: one operation's record + verdict + playbooks, or one resource's relationship map. */
12
12
  export declare function buildDescribe(ctx: ToolContext): BuiltTool;
13
+ /** The single place to extend: entities the registry has no create for, yet the vendor documentation lists one, so the
14
+ * note would be false. `time_off_requests`: the swagger has a child create (POST /V1.0/Resources/{parentId}/TimeOffRequests,
15
+ * TimeOffRequestsChild_CreateEntity) and the SDK defines TimeOffRequestCreate; the SDK plan simply has no create row. */
16
+ export declare const NO_CREATE_EXCLUDED: ReadonlySet<string>;
17
+ /** The entities that carry the noCreate note (pinned by name in a test). */
18
+ export declare const NO_CREATE_ENTITIES: ReadonlySet<string>;
13
19
  /** `autotask_read`: one read by exact key, or up to 10 (`items`) with per-item results. */
14
20
  export declare function buildRead(ctx: ToolContext): BuiltTool;
15
21
  /** `autotask_tenant_status`: identity / version / thresholds / server / time; each part degrades to typed absent. */
@@ -18,6 +18,7 @@
18
18
  import { AutotaskConfigError } from 'node-autotask';
19
19
  import { effectiveRequiredFields } from 'node-autotask/operations';
20
20
  import { UDF_MERGE_NOTE, UDF_NONE, UDF_ROWS_NOTE, associationFields, associationRules, caveatNotes, constraintNotes, coupledNotes, dateConventionNotes, statusScopeNote, udfDefinitionRows, udfMergeNote, udfMoreNote, udfUnavailable } from './write-guidance.js';
21
+ import { scrubDeep, scrubProvenance } from './provenance-scrub.js';
21
22
  import { CATALOG, RECORDS, SDK_VERSION, TOOL_NAME_BY_OP, UDF_CAPABLE, opsForTool, recordById, servedToolName, untrustedFieldsOf } from './catalog-data.js';
22
23
  import { releaseClientOnError } from './context.js';
23
24
  import { abortedAnswer, InputInvalidError, readOneItem, requestSecrets } from './dispatch.js';
@@ -252,12 +253,14 @@ const ENTITY_RESOURCES = new Set(RECORDS.filter((r) => r.id.endsWith('.getEntity
252
253
  /** The single place to extend: entities the registry has no create for, yet the vendor documentation lists one, so the
253
254
  * note would be false. `time_off_requests`: the swagger has a child create (POST /V1.0/Resources/{parentId}/TimeOffRequests,
254
255
  * TimeOffRequestsChild_CreateEntity) and the SDK defines TimeOffRequestCreate; the SDK plan simply has no create row. */
255
- const NO_CREATE_EXCLUDED = new Set(['time_off_requests']);
256
+ export const NO_CREATE_EXCLUDED = new Set(['time_off_requests']);
257
+ /** The entities that carry the noCreate note (pinned by name in a test). */
258
+ export const NO_CREATE_ENTITIES = new Set([...ENTITY_RESOURCES].filter((r) => !CREATE_RESOURCES.has(r) && !NO_CREATE_EXCLUDED.has(r)));
256
259
  const NO_CREATE_NOTE = 'do not look for a create operation: this server has none for this entity, and the vendor API documentation lists none.';
257
260
  /**
258
261
  * S8 (#183): the UDF block for `describe {resource}`: the mechanism facts (SDK row shape) + the definitions
259
262
  * pointer. Zero wire: the capability fact is the registry itself (UDF_CAPABLE: the resources whose records
260
- * stamp UDF1, the plan's udfCapable entities; SDK 0.34.0 #219 N7), and the definitions are the live tenant
263
+ * carry the published udfCapable flag, SDK 0.37.0 #254; SDK 0.34.0 #219 N7), and the definitions are the live tenant
261
264
  * read the pointer names (the scoped fieldCatalog read with the `udf` include).
262
265
  */
263
266
  function udfBlock(resource) {
@@ -314,7 +317,7 @@ async function guidanceFor(ctx, rec, reachable) {
314
317
  // #173: the positional [from, to, opts] mixes types; the same window as one uniform-string filter
315
318
  return {
316
319
  exampleArgs: ['2026-10-20', '2026-10-22', { resourceID: 123 }],
317
- sameWindowAsQuery: 'autotask_query {resource:"time_entries", filter:[{field:"resourceID",op:"eq",value:"123"},{field:"dateWorked",op:"gte",value:"2026-10-20"},{field:"dateWorked",op:"lte",value:"2026-10-22"}], limit:500}: the same rows up to limit (the window caps at 500; dateWorked is a date, both days inclusive), without the posted counts, for a client that cannot send a mixed-type array.',
320
+ sameWindowAsQuery: 'autotask_query {resource:"time_entries", filter:[{field:"resourceID",op:"eq",value:"123"},{field:"dateWorked",op:"gte",value:"2026-10-20"},{field:"dateWorked",op:"lte",value:"2026-10-22"}], limit:500}: the same rows up to limit (one window call returns at most 500 and a nextCursor to pass as cursor for the rest; dateWorked is a date, both days inclusive), without the posted counts, for a client that cannot send a mixed-type array.',
318
321
  };
319
322
  }
320
323
  if (op === 'moveToTicket') {
@@ -350,8 +353,9 @@ async function guidanceFor(ctx, rec, reachable) {
350
353
  // The SDK's one precedence (live-proven > zone > spec) is a create fact; an update keeps the vendor's own set.
351
354
  const zone = op === 'create' ? await zoneFactsOf(ctx, rec.resource) : undefined;
352
355
  facts = zone?.createGuidance ?? undefined;
353
- // the SDK's quirks are free text (provenance, tenant names, operator advice): none of it is served on a create, only a pointer
354
- hasQuirks = (zone?.quirks?.length ?? 0) > 0;
356
+ // quirk statements are SDK free text: none is served on a create, only a pointer, and only when a quirk
357
+ // names the create op (SDK 0.37.0 #253 structured quirks; #224)
358
+ hasQuirks = (zone?.quirks ?? []).some((q) => Array.isArray(q?.ops) && q.ops.includes('create'));
355
359
  const eff = op === 'create' ? effectiveRequiredFields({ requiredFields: spec }, zone) : { fields: spec, from: 'spec', unproven: true };
356
360
  // eff.fields is already the unconditional set (value-gated fields are in eff.conditional, SDK 0.34.0)
357
361
  required = eff.fields;
@@ -414,21 +418,32 @@ async function zoneFactsOf(ctx, resource) {
414
418
  }
415
419
  /** The one read envelope of autotask_read and autotask_fetch_many (#97), served on the on-demand surface. */
416
420
  const READ_ENVELOPE = 'data = { results: Slot[], ok: <count>, failed: <count>, budget?, budgetWarning? }, always an array in input order, one slot for a single read. Slot = { ok: true, operation, result, windowNote?, scopeNote? } or { ok: false, operation, error }; autotask_fetch_many slots also echo resource and id (operation is "<resource>.get"; a miss is { ok: false, error: { code: "NOT_FOUND" } }). A single read that fails still answers the whole-call error envelope.';
417
- /** Served relationship map: createGuidance without the SDK's free text (`vendorEvidence`, `blocker.message`/`remedy`). */
421
+ /** Served relationship map without the SDK's free text: createGuidance loses `vendorEvidence` and
422
+ * `blocker.message`/`remedy`; each structured `zoneFacts.quirks` entry loses `evidence` (SDK 0.37.0 #253: raw
423
+ * campaign strings) in {@link scrubDeep}, which drops every string `evidence` key and scrubs the rest (#224). */
418
424
  function projectMap(map) {
419
425
  const m = map;
420
- const g = m?.zoneFacts?.createGuidance;
421
- if (g === undefined || g === null || typeof g !== 'object')
422
- return map;
423
- const slim = { ...g };
424
- delete slim.vendorEvidence;
425
- if (slim.blocker !== null && typeof slim.blocker === 'object') {
426
- const b = { ...slim.blocker };
427
- delete b.message;
428
- delete b.remedy;
429
- slim.blocker = b;
426
+ const z = m?.zoneFacts;
427
+ if (z === undefined || z === null || typeof z !== 'object')
428
+ return scrubDeep(map);
429
+ const zone = { ...z };
430
+ // #224: a live write keeps its op and outcome; its date and note are maintainer history
431
+ const lw = z.liveWrites;
432
+ if (Array.isArray(lw))
433
+ zone.liveWrites = lw.map((w) => (w !== null && typeof w === 'object' ? { op: w.op, outcome: w.outcome } : w));
434
+ const g = z.createGuidance;
435
+ if (g !== undefined && g !== null && typeof g === 'object') {
436
+ const slim = { ...g };
437
+ delete slim.vendorEvidence;
438
+ if (slim.blocker !== null && typeof slim.blocker === 'object') {
439
+ const b = { ...slim.blocker };
440
+ delete b.message;
441
+ delete b.remedy;
442
+ slim.blocker = b;
443
+ }
444
+ zone.createGuidance = slim;
430
445
  }
431
- return { ...m, zoneFacts: { ...m.zoneFacts, createGuidance: slim } };
446
+ return scrubDeep({ ...m, zoneFacts: zone });
432
447
  }
433
448
  /** Served record: `metadata.caveats` rows keep kind/fields/remedyOp, not the SDK's free-text `note`. */
434
449
  function projectCaveats(rec) {
@@ -458,14 +473,15 @@ function servedUsage(rec) {
458
473
  // the read-back is one `<resource>.get`: a create with no get in the registry answers `data.record: null`
459
474
  return u.includes(CREATE_USAGE_FALSE) && recordById(`${rec.resource}.get`) !== undefined ? u.replace(CREATE_USAGE_FALSE, CREATE_USAGE_TRUE) : u;
460
475
  }
461
- /** Served record: caveats projected and the create usage corrected (AC24). */
476
+ /** Served record: caveats projected, the create usage corrected (AC24), the SDK's own test list dropped and
477
+ * its maintainer provenance scrubbed (#224). */
462
478
  function servedRecord(rec) {
463
- const projected = projectCaveats(rec);
479
+ const projected = { ...projectCaveats(rec) };
480
+ delete projected.tests;
464
481
  const md = projected.metadata;
465
- const usage = servedUsage(projected);
466
- if (typeof md?.usage !== 'string' || usage === undefined || md.usage === usage)
467
- return projected;
468
- return { ...projected, metadata: { ...md, usage } };
482
+ const usage = servedUsage(rec);
483
+ const out = typeof md?.usage !== 'string' || usage === undefined || md.usage === usage ? projected : { ...projected, metadata: { ...md, usage } };
484
+ return scrubDeep(out);
469
485
  }
470
486
  /** The per-field caps of a describe summary (characters): purpose, args and usage only, about 2 KB in all (#197). */
471
487
  const SUMMARY_CAPS = { purpose: 500, args: 600, usage: 400 };
@@ -484,13 +500,13 @@ function summariseRecord(ctx, operation, viaTool) {
484
500
  const reason = refusalReason(operation, rec, ctx.config, ctx.writable);
485
501
  const md = (rec.metadata ?? {});
486
502
  const row = CATALOG.catalog.find((r) => r.operation === operation);
487
- const text = (v) => (typeof v === 'string' ? v : '');
503
+ const text = (v) => (typeof v === 'string' ? scrubProvenance(v) : '');
488
504
  const cut = [];
489
505
  const tool = viaTool ?? TOOL_NAME_BY_OP[operation];
490
506
  return ok(`Summary of "${rec.id}" (purpose, args, usage).`, {
491
507
  operation: rec.id,
492
508
  ...(tool !== undefined ? { tool, ...toolServed(ctx, tool) } : {}),
493
- purpose: capText(text(md.purpose) || (row?.purpose ?? ''), SUMMARY_CAPS.purpose, 'purpose', cut),
509
+ purpose: capText(text(md.purpose) || text(row?.purpose), SUMMARY_CAPS.purpose, 'purpose', cut),
494
510
  requiredArgs: row?.requiredArgs ?? [],
495
511
  args: capText(text(md.args), SUMMARY_CAPS.args, 'args', cut),
496
512
  usage: capText(text(servedUsage(rec)), SUMMARY_CAPS.usage, 'usage', cut),
@@ -513,7 +529,7 @@ async function describeRecord(ctx, operation, viaTool, summary = false) {
513
529
  ...(guidance !== undefined ? { guidance } : {}),
514
530
  ...(rec.effect === 'read' && (viaTool === 'autotask_fetch_many' || (viaTool === undefined && !rec.id.endsWith('.query'))) ? { readEnvelope: READ_ENVELOPE } : {}),
515
531
  record: { ...servedRecord(rec), untrustedFields: untrustedFieldsOf(rec) },
516
- ...(rec.effect === 'read' && ENTITY_RESOURCES.has(rec.resource) && !CREATE_RESOURCES.has(rec.resource) && !NO_CREATE_EXCLUDED.has(rec.resource) ? { noCreate: NO_CREATE_NOTE } : {}),
532
+ ...(rec.effect === 'read' && NO_CREATE_ENTITIES.has(rec.resource) ? { noCreate: NO_CREATE_NOTE } : {}),
517
533
  reachable: reason === null,
518
534
  why_not: reason,
519
535
  playbooks: playbooksFor(rec.id),
@@ -0,0 +1,20 @@
1
+ /**
2
+ * tools/provenance-scrub.ts — maintainer provenance out of the SDK text `autotask_describe` serves (#224).
3
+ *
4
+ * The SDK's registry records and relationship maps carry its own build history inside otherwise useful
5
+ * guidance: the test zone (`ww29`), verification dates, issue numbers, campaign stages and markers, wall
6
+ * ids. None of it helps a model call the API, and some of it is tenant-identifying. Only the provenance
7
+ * tokens are removed: a parenthetical is split into clauses (`;`, ` — `, `, ` at its own depth) and only a
8
+ * clause that itself carries a marker is edited; a bare date survives unless its clause carries a marker
9
+ * (a format example such as `e.g. 2026-01-01` stays); a parenthetical left empty is dropped.
10
+ * Deterministic text surgery, not a guarantee: a marker the patterns below do not name passes through.
11
+ * ponytail: pattern list, extend it when an SDK bump adds a new marker shape.
12
+ */
13
+ /** Object nesting bound for {@link scrubDeep}; deeper subtrees are dropped, never served unscrubbed. */
14
+ export declare const MAX_DEEP = 32;
15
+ export declare const MAX_PAREN_DEPTH = 16;
16
+ /** `text` with the provenance tokens removed (a text with no marker is returned as it is). */
17
+ export declare function scrubProvenance(text: string): string;
18
+ export declare function scrubExample(text: string): string;
19
+ /** A deep copy with every string scrubbed (`examples` by {@link scrubExample}) and the provenance-only string keys dropped. */
20
+ export declare function scrubDeep(value: unknown, depth?: number): unknown;