node-autotask 0.21.0 → 0.24.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.
Files changed (56) hide show
  1. package/ARCHITECTURE.md +44 -21
  2. package/CHANGELOG.md +44 -0
  3. package/MCP_TOOL_CATALOG.json +90 -10
  4. package/MCP_TOOL_MANIFEST.md +24 -20
  5. package/MCP_TOOL_OVERRIDES.json +6 -6
  6. package/N8N_MCP_MANIFEST.json +65 -10
  7. package/N8N_MCP_MANIFEST.md +10 -8
  8. package/README.md +9 -9
  9. package/capabilities.json +3247 -7575
  10. package/capabilities.schema.json +0 -12
  11. package/dist/capabilities.cjs +2 -2
  12. package/dist/capabilities.d.cts +0 -2
  13. package/dist/capabilities.d.ts +0 -2
  14. package/dist/capabilities.js +1 -1
  15. package/dist/{chunk-CBUY3MKB.cjs → chunk-4VAC2Z55.cjs} +3246 -7574
  16. package/dist/{chunk-KD6LOSAK.js → chunk-BBQBWVBE.js} +3496 -3082
  17. package/dist/{chunk-JRSU6PKP.js → chunk-ICCU2S62.js} +2 -2
  18. package/dist/{chunk-GBVUXAUA.js → chunk-IOVFTIW6.js} +2257 -1426
  19. package/dist/{chunk-QWAWNQEZ.cjs → chunk-LVBFLSPL.cjs} +3044 -2630
  20. package/dist/{chunk-PMPFQDVM.cjs → chunk-SOHQ6SDI.cjs} +135 -135
  21. package/dist/{chunk-HEW4WJLC.cjs → chunk-SW66OWM5.cjs} +2645 -1814
  22. package/dist/{chunk-DM67SXCO.js → chunk-WJD26CPN.js} +3246 -7574
  23. package/dist/{index-BXtKN1M5.d.cts → index-BVtycq-6.d.cts} +1 -1
  24. package/dist/{index-BXtKN1M5.d.ts → index-BVtycq-6.d.ts} +1 -1
  25. package/dist/{index-ByWYfNog.d.cts → index-C0OwQhon.d.cts} +486 -962
  26. package/dist/{index-DSnuyiTr.d.ts → index-CZ94y401.d.ts} +486 -962
  27. package/dist/index.cjs +91 -62
  28. package/dist/index.d.cts +360 -5
  29. package/dist/index.d.ts +360 -5
  30. package/dist/index.js +74 -45
  31. package/dist/operations/index.cjs +462 -462
  32. package/dist/operations/index.d.cts +78 -84
  33. package/dist/operations/index.d.ts +78 -84
  34. package/dist/operations/index.js +382 -382
  35. package/dist/pool.cjs +8 -8
  36. package/dist/pool.d.cts +2 -2
  37. package/dist/pool.d.ts +2 -2
  38. package/dist/pool.js +4 -4
  39. package/dist/resources/index.cjs +4 -4
  40. package/dist/resources/index.d.cts +2 -2
  41. package/dist/resources/index.d.ts +2 -2
  42. package/dist/resources/index.js +3 -3
  43. package/dist/types/index.d.cts +1 -1
  44. package/dist/types/index.d.ts +1 -1
  45. package/docs/API.md +170 -49
  46. package/docs/N8N-ADAPTER.md +3 -3
  47. package/examples/mcp/errors.ts +135 -0
  48. package/examples/mcp/manifest.ts +168 -0
  49. package/examples/mcp/register.ts +66 -0
  50. package/examples/mcp/schemas.ts +203 -0
  51. package/examples/mcp/tools-meta.ts +120 -0
  52. package/examples/mcp/tools-mode.ts +177 -0
  53. package/examples/mcp/tools-read.ts +264 -0
  54. package/examples/mcp/types.ts +34 -0
  55. package/examples/mcp-register.ts +9 -1074
  56. package/package.json +1 -1
package/ARCHITECTURE.md CHANGED
@@ -64,25 +64,28 @@ proves the mapping op-by-op.
64
64
  src/
65
65
  index.ts # Public API barrel (default + named exports)
66
66
  client.ts # AutotaskClient — top-level facade; wires all resources + operations
67
- config.ts # AutotaskConfig, AutotaskClientOptions, zone catalog + zoneUrl(), validation, defaults
68
- http.ts # HttpClient — fetch transport, URL/query building, POST/GET query switch,
69
- # envelope unwrap helpers (pinned to the G1 envelopes)
67
+ config.ts # barrel -> config/ (types, zones + zoneUrl(), defaults, profiles, resolve/validation)
68
+ http.ts # barrel -> http/ (HttpClient.ts = fetch transport; request-builders.ts = URL/query
69
+ # building + POST/GET switch; envelope.ts = unwrap helpers pinned to the G1
70
+ # envelopes; retry.ts, breaker-budget.ts, zone.ts, credentials.ts, body.ts, types.ts)
70
71
  auth.ts # 3-header auth (ApiIntegrationCode/UserName/Secret) + ImpersonationResourceId
71
72
  # + authenticate() token alternative; credential redaction list
72
- errors.ts # AutotaskError hierarchy + errorFromStatus + vendor error-body parsing
73
+ errors.ts # barrel -> errors/ (base.ts hierarchy root, http.ts transport errors, mutation.ts,
74
+ # from-status.ts errorFromStatus, vendor-errors.ts vendor error-body parsing)
73
75
  pagination.ts # Page<T>, cursor-follow walk (nextPageUrl), collectAll(), toArray()
74
76
  query.ts # Filter builder: 13 operators + and/or grouping, caps, GET<=2048 measurement
75
77
  logger.ts # Logger interface + NoopLogger
76
- utils.ts # assertRecord, deep-merge of params, identifier helpers, UDF flatten
78
+ utils/index.ts # assertRecord, deep-merge of params, identifier helpers, UDF flatten
77
79
  capabilities.ts # GENERATED capability registry (./capabilities) — never hand-edited
78
80
  operations/
79
81
  index.ts # ./operations barrel
80
- search-across.ts # searchAcrossResources (cross-resource bounded text search)
81
- resolve-any.ts # resolveAny (cross-resource identifier resolution)
82
- invoke.ts # registry-driven invoke + write governor (G6; MCP projection consumer)
82
+ searchAcrossResources.ts # cross-resource bounded text search
83
+ resolveAny.ts # cross-resource identifier resolution
84
+ invoke.ts + invoke/ # registry-driven invoke + write governor (dispatch, validate, modes, wrappers, ...)
85
+ ... # aggregate, fetchMany, fetchByIds, recovery, relationshipMap, probeReachability, ...
83
86
  resources/
84
87
  index.ts # Barrel for all resource classes + resources() accessor
85
- base.ts # BaseResource<T> — shared query/get/create/update/delete scaffolding,
88
+ base.ts + base/ # BaseResource<T> (base/BaseResource.ts; query/mutations/meta/helpers split) — shared query/get/create/update/delete scaffolding,
86
89
  # entityInfo ops, dry-run scaffolding, scan guards
87
90
  companies.ts # CompaniesResource (top-level entity template — see §12.1)
88
91
  company_notes.ts # CompanyNotesResource (parent-scoped child template — see §12.2)
@@ -99,12 +102,14 @@ src/
99
102
  test/
100
103
  __fixtures__/ # Mock JSON responses per resource (derived from api-docs.json definitions)
101
104
  client.test.ts # AutotaskClient construction + wiring
102
- http.test.ts # transport, envelopes, error mapping, retries, POST/GET query switch
105
+ http/ # one file per concern: transport, envelope, retry, query-builders, hardening,
106
+ # concurrency, logging, mutation-retry
103
107
  pagination.test.ts # cursor-follow walk (nextPageUrl), 1000-page guard
104
108
  query.test.ts # filter builder, caps, GET-length fallback
105
109
  config.test.ts # zone/baseUrl/credentials/pageSize validation
106
110
  auth.test.ts # header shape, impersonation, redaction
107
111
  errors.test.ts # error hierarchy + vendor error-body parsing + item:null semantics
112
+ # (plus errors-*.test.ts and transport-*.test.ts concern files)
108
113
  operations/ # searchAcrossResources / resolveAny tests
109
114
  resources/
110
115
  companies.test.ts # one spec-per-resource file, alongside each resource
@@ -273,7 +278,7 @@ shape only — deliberately.** Do not "fix" this without reading the reason:
273
278
 
274
279
  ## 6. Config, validation, and defaults
275
280
 
276
- ### `config.ts`
281
+ ### `src/config/`
277
282
  ```ts
278
283
  export type ZoneName =
279
284
  | 'pre-release' | 'pre-release-uk' | 'limited-release' | 'limited-release-uk'
@@ -484,7 +489,7 @@ Vendor filter structure (swagger `Filter`, recursive):
484
489
 
485
490
  ---
486
491
 
487
- ## 8. HTTP transport (`http.ts`)
492
+ ## 8. HTTP transport (`src/http/`)
488
493
 
489
494
  - Uses the **native global `fetch`** (native since Node 18; this package's floor is Node >= 24). **Zero runtime dependencies** across the
490
495
  whole package.
@@ -571,18 +576,18 @@ export interface RequestOptions {
571
576
  auto-unwrap — envelope unwrapping is an explicit per-call decision made by the resource
572
577
  layer using the helpers below, pinned to the G1 envelopes.
573
578
  10. **Redirects are never followed**: every request is sent with `redirect: 'manual'`
574
- (`src/http.ts:911`; the zone-discovery call `:673` likewise, with the same comment). A
579
+ (`HttpClient.ts`; the zone-discovery call in `src/http/zone.ts` likewise, with the same comment). A
575
580
  `3xx` on a normal request cancels the body and throws immediately — no second request
576
581
  is made:
577
582
  - error: `ResponseRejectedError` with `code: 'REDIRECT_BLOCKED'`, `category: 'policy'`,
578
583
  `retryable: false`, `httpStatus` = the 3xx status, and `suggestedAction: 'Check the
579
- configured zone/baseUrl; the API does not redirect.'` (`src/errors.ts:135` for the
580
- class; the throw site is `src/http.ts:940`).
584
+ configured zone/baseUrl; the API does not redirect.'` (`ResponseRejectedError` in
585
+ `src/errors/http.ts`; the throw site is in `src/http/HttpClient.ts`).
581
586
  - why: **this SDK attaches credentials to every request**, and a native redirect
582
587
  follow re-sends those headers to the redirect target — even cross-origin. Wrapping
583
588
  the refusal in a typed error is deliberate (issue #27 shape).
584
589
  - the `Location` header is never read, so an attacker-supplied target cannot reach the
585
- caller through the message (`test/http.test.ts:530` — "never follows redirects:
590
+ caller through the message (`test/http/hardening.test.ts` — "never follows redirects:
586
591
  sends redirect `'manual'` and refuses a 3xx without a second request" — pins exactly
587
592
  one wire call, `redirect: 'manual'`, `code: 'REDIRECT_BLOCKED'`, `retryable: false`,
588
593
  and no `evil.example` in the text). Shipped in v0.3.1 (`CHANGELOG.md`); the same
@@ -597,7 +602,7 @@ export interface RequestOptions {
597
602
  redirect resolved, and the skill's `manualRedirect` escape hatch (only for endpoints
598
603
  whose contract IS a redirect) does not apply here; the builder does not implement one.
599
604
 
600
- ### Envelope unwrapping helpers (in `http.ts`, used by `BaseResource`)
605
+ ### Envelope unwrapping helpers (in `src/http/envelope.ts`, used by `BaseResource`)
601
606
  ```ts
602
607
  /** query: expects { items: T[], pageDetails: PagingModel }; validates both present. */
603
608
  export function unwrapItems<T>(body: unknown): { items: T[]; pageDetails: PagingModel };
@@ -669,7 +674,7 @@ export function withAuthHeaders(
669
674
 
670
675
  ---
671
676
 
672
- ## 10. Error hierarchy (`errors.ts`)
677
+ ## 10. Error hierarchy (`src/errors/`)
673
678
 
674
679
  ```
675
680
  AutotaskError (base; extends Error)
@@ -1000,7 +1005,7 @@ export const NoopLogger: Logger = {};
1000
1005
 
1001
1006
  ## 16. Testing strategy
1002
1007
 
1003
- - **HTTP layer (`http.test.ts`)**: mocked global `fetch` — URL construction, auth headers
1008
+ - **HTTP layer (`test/http/*.test.ts`)**: mocked global `fetch` — URL construction, auth headers
1004
1009
  (swagger casing + impersonation), POST/GET query switch (incl. 2048-char `QUERY_TOO_LONG`),
1005
1010
  envelope pass-through, per-envelope unwrap errors, error mapping per status (vendor error
1006
1011
  body formatting), retry/backoff on 429 with `Retry-After`, `Itgenatr005` as retryable
@@ -1022,7 +1027,7 @@ export const NoopLogger: Logger = {};
1022
1027
  - **Coverage gate** (vitest config): lines >= 90 / functions >= 90 / branches >= 80 /
1023
1028
  statements >= 90 (coordinator default from SCOPING F; node-hudu's 97/94/83/97 is the
1024
1029
  stretch target — ratchet at v0.2 once generated-resource tests settle).
1025
- - **Additive-only surface proof**: `test/public-surface.test.ts` compares the live
1030
+ - **Additive-only surface proof**: `scripts/public-surface.mjs` (exercised by `test/scripts/generated-gates.test.ts`) compares the live
1026
1031
  exported surface against `test/__fixtures__/public-surface.json`, captured at the G3
1027
1032
  baseline. Removing a baseline name fails the suite; additions are reported, not failed.
1028
1033
  - **Capability gate**: `check-capabilities.mjs` ships with a committed negative fixture
@@ -1036,7 +1041,7 @@ Gates G3–G7 of `.run/CHECKLIST.md`; the per-batch loop never regenerates the w
1036
1041
  until G6 (mechanism in `.run/design/generator-design.md` §10):
1037
1042
 
1038
1043
  1. **G3 — Core + scaffold.** `package.json`/tsconfig/tsup/vitest/eslint; `logger.ts`,
1039
- `errors.ts`, `auth.ts`, `utils.ts` (no deps) → `config.ts` (+tests) → `http.ts`
1044
+ `errors/`, `auth.ts`, `utils/` (no deps) → `config/` (+tests) → `http/`
1040
1045
  (+tests) → `pagination.ts`, `query.ts` (+tests) → `types/common.ts` →
1041
1046
  `resources/base.ts` → capture the public-surface fixture. Green:
1042
1047
  `npm run typecheck && npm test && npm run build`.
@@ -1258,3 +1263,21 @@ What it ships instead:
1258
1263
  `examples/mcp-server-http.ts` (SSE/streamable-HTTP transport). They are the reference
1259
1264
  implementations the future Autotask MCP server package is built from; the SDK itself
1260
1265
  remains MCP-import-free.
1266
+
1267
+ ### 19.6 Composite moves (issue #197)
1268
+
1269
+ Some vendor entities accept an update of their parent field and silently keep the
1270
+ old value (live-probed on ww29: time entries and ticket charges; ticket notes DO
1271
+ move on a parent-scoped update). Where the entity can be deleted, a move is a
1272
+ composition on the shared engine (`special/_shared/move-engine.ts`): GET-only preview
1273
+ -> hard invariants -> twin check -> create (once) -> read-back compare -> source
1274
+ re-read -> delete (once), with rollback of the new record on any mismatch or drift.
1275
+ The engine returns only for `moved` / `moved_source_absent` / `moved_manual_delete`;
1276
+ every other outcome throws a typed `MoveRefusedError` carrying `details.status`
1277
+ (`failed` / `partial` / `unknown`). Entities the API cannot move or cannot finish
1278
+ deleting hand the person a `manualAction` with an `operations.uiLink` deep link
1279
+ (the record where ExecuteCommand supports it, else its parent ticket). The SDK
1280
+ authorises nothing (company scope and ownership are the caller's job) and stores
1281
+ nothing (the source snapshot is returned). A new resource joins by supplying a
1282
+ `MoveSpec` (copy set, invariants, verify list) and one plan row.
1283
+
package/CHANGELOG.md CHANGED
@@ -7,6 +7,50 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.24.0] - 2026-10-23
11
+
12
+ The plan hash moves (MINOR) — plan-pinned consumers must re-pin. The 0.24.0 plan hash is `479d7826acf5…` (recorded in `scripts/published-plan-hashes.json` after publish).
13
+
14
+ ### Fixed
15
+
16
+ - **`tz` on the time-entry `dateWorked` windows picked the wrong day for a zone behind UTC** (live-verified 2026-10-23: `America/Los_Angeles` asked for the 23rd returned the 24th's rows). The vendor stores `dateWorked` as a date-only value at 00:00 UTC, but `tz` converted the local day to UTC instants. `time_entries.postedWithin`, `dateWorkedWindow`, `customRange` and `recentTickets` now treat `from`/`to` as the calendar days they are (`tz` is validated but does not shift them), and the relative windows (`today`, `last7`..`last90`, `lastFullWeek`/`Fortnight`/`Month`/`Quarter`, `yesterday`) use `tz` only to pick which calendar dates apply, sending whole UTC days of them (`last24h` stays rolling). Zones ahead of UTC (e.g. `Australia/Sydney`) return the same rows as before; the `window` field in `postedWithin`/`dateWorkedWindow`/`recentTickets` results now reports whole UTC days instead of zone-converted instants. `billing_items` and `invoices` window ops are unchanged.
17
+
18
+ ### Added (#210)
19
+
20
+ - `tickets.search(query, { limit, companyID?, includeFields? })`: an optional company scope (a `companyID eq` leaf and-joined server-side with the title `contains`) and a column projection; `searchWithDetail` inherits both. A malformed `companyID`, `includeFields` or an unknown option key is refused before any request (a misspelt `companyId` cannot silently widen the search); `id` is always kept in the projection, and the empty `userDefinedFields: []` the vendor appends to projected rows is dropped (a non-empty one is kept; no projection, no change). Additive; `search` on every other resource is unchanged.
21
+ - `time_entries.recentTickets(from, to, { resourceID, companyID?, tz?, limit? })`: the distinct tickets a resource logged time against in an inclusive `dateWorked` window, with entry count, total hours, last worked date and the ticket's number, title, status, assignee and company, newest first. One bounded time-entry read plus chunked `id in` ticket reads. `companyID` is applied after the join, client-side on the ticket rows (TimeEntry has no `companyID`), so `limit` bounds the entries scanned and other companies' tickets are only counted in `excludedTickets`; the result carries `truncated` in the `postedWithin` sense, plus `entriesWithoutTicket` and `unresolvedTicketIDs` (tickets the read did not return). New plan row (registry now 1217 ops / 854 tools).
22
+
23
+ ## [0.23.0] - 2026-10-21
24
+
25
+ The plan hash moves (MINOR) — plan-pinned consumers must re-pin. The 0.23.0 plan hash is `95746a69c86d…` (recorded in `scripts/published-plan-hashes.json` after publish).
26
+
27
+ **W-A3 time-entry zone fact re-scoped (issue #195).** The 2026-10-06 note claimed the vendor "refuses ALL time-entry writes for this API-only user" (UNSATISFIABLE); live `time_entries.create` calls succeeded on 2026-10-21 (pair not recorded; two entries, read back, 1.0 h). The served wording (plan `pointerNotes` + `createGuidance` blocker message/remedy, the four time-entry tool-description overrides, `docs/API.md`) is now a (resource, role) pair-scoped wall: the pair selecting the API-only user's own resource was refused 2026-10-06. The authorised pair set has NOT been re-adjudicated by a live probe.
28
+ - Single source: the wording lives in `capabilities.plan.json`, regenerated into `capabilities.json`, `src/capabilities.ts`, `src/relationships.ts` and the MCP/n8n manifests and catalog.
29
+ - The plan records the success as `zoneFacts.resources.time_entries.liveWrites[]`; new gate clause 18 (`scripts/lib/checks/clause18-zone-fact-liveness.mjs`, test `test/scripts/zone-fact-liveness.test.ts`) fails when a plan zone fact (blocker message/remedy, pointer note) or an `MCP_TOOL_OVERRIDES.json` row asserts a wall (UNSATISFIABLE / refuses all, case-insensitive) stamped before a recorded successful live write of the same op.
30
+ - `scripts/s13b-plan-patch.mjs` no longer writes `pointerNotes` (its archived copy would overwrite the re-stamped rows with older-dated text).
31
+ - Invoke (#196): the query/count field-name refusal now embeds the entity's first 20 field names (`(and N more)` beyond that) in `err.message`, and its suggestedAction points at `operations.fieldCatalog` scoped with `resources: ["<entity>"]`; no extra wire. No plan-hash change.
32
+
33
+ ### Added (wave-1: #197, #198, #200, #201, #202)
34
+
35
+ - Governed create-verify-delete moves (#197): `time_entries.moveToTicket` and `ticket_charges.moveToTicket` on a shared move engine, because the vendor accepts an update of `ticketID` but silently keeps the old value (live-probed). Dry-run-first (GET-only preview), `confirm` required, typed `MOVE_*` refusals (billed/approved/attachments/too old/twin/closed target), read-back compare, source re-read, rollback, results `moved` / `moved_source_absent` / `moved_manual_delete`, with a `manualAction` carrying an `operations.uiLink` deep link where a person must finish in the UI. New `MoveRefusedError`. Served hints now warn that `ticketID` does not persist on update and that deleting a time entry destroys its attachments; `ticket_notes` update documents that the scoped `ticketID` update moves a note. **Plan hash moves:** `bc4308db3dec…` → `95746a69c86d…` (MINOR; recorded after publish). Registry now 1216 ops / 853 tools.
36
+ - `companies.fullProfile` per-part field projection and `locationLimit` (#200): `fields: { contacts: ['id','firstName'], openTickets: [...] }` serves only the listed fields for a listed part (`IncludeFields` on the wire, then picked client-side); unlisted parts and the no-`fields` call are unchanged. Unknown part key or field name is refused pre-request (zero wire). `locationLimit` (1-500, default 50) replaces the fixed locations cap and drives `locations.capped`. Additive, minor; the plan hash is unchanged (the registry record and generated artifacts carry the new bag keys).
37
+ - `operations.uiLink` (#202): a pure, zero-wire meta op (plus the `buildUiLink` helper) that builds Autotask ExecuteCommand deep links for time entries, tickets (by id or ticket number), tasks, projects, companies, contacts, contracts and configuration items. The `ww<N>` UI host is derived from the client's `webservices<N>` base, never guessed (no link for `pre-release-de`/`-es` or custom hosts unless `host` is passed). Ids are positive safe integers, ticket numbers match `^T\d{8}\.\d{4}$`. **Plan hash moves** (new meta op row): `03a3bf610a85…` → `bc4308db3dec…` (MINOR; recorded after publish). Registry now 1214 ops / 851 tools.
38
+ - `operations.searchAcrossResources` accepts an optional `totalLimit` (#201): caps matches across all resources; resources are searched in request order, each asked for `min(limit, remaining)`, later ones are not queried and are reported in `notSearched`, with `truncated`. Absent `totalLimit` the result is unchanged. `limit` is now documented as per-resource (the old header said "one limit budget"). Additive, minor.
39
+
40
+ ### Changed (wave-1: #198)
41
+
42
+ - Positional-arity refusal text (#198): dropped the v0.2.0 history aside, and when the surplus arg is a plain object on an op that declares an options bag it adds "options go inside the single `<bag>` object". Message text only; no behaviour or plan change.
43
+
44
+ ## [0.22.0] - 2026-10-21
45
+
46
+ **Plan `tests[].file` paths refreshed (issues #194, #189).** The plan hash moves (MINOR) — plan-pinned consumers must re-pin. 111 `operations[].tests[]` rows that named pre-split test files now name the file where their title lives; titles are unchanged and the drift gate still resolves by title, so there is no behavioural change.
47
+ - Transport (#184, #190): the per-request deadline timer is now owned by one `try/finally` from the moment it is armed, so a cooldown or breaker refusal, or a throwing `logger.debug` / audit-body redaction, can no longer keep the event loop alive for `timeoutMs`; regression tests spy on `clearTimeout`.
48
+ - Types (#191): the 24 `BaseResource` members that went `private` -> `protected` only to span the query/meta/mutations layers (`cachedGet`, `walkBounded`, `latestWalk`, `unwrapCreateOperation`, ...) are marked `@internal` and stripped from the published `.d.ts` (`stripInternal`); they are not public API and subclasses outside the package cannot rely on them.
49
+ - Tooling (#187, #188, #189): the cache-write-invalidation static guard now scans every `boundedPart`/`readPart`/`windowedPart`/`expandByMonth` caller across all of `src/`; `derive-plan` finds BaseResource methods in `src/resources/base/{query,meta,mutations}.ts`; the test-title lookup ignores comments and `emit(`/`submit(` lookalikes, honours the injected `readFile`, and the capability gate fails a pinned title declared in more than one test file.
50
+
51
+ - Capability gate (#185): plan `tests[]` rows are now resolved by title across `test/**` (the `file` field is informational); `staleCheck.testFiles`/`testHash` are dropped from the registry. Plan hash unchanged. Test files can be renamed/split freely; the six sprint-coded test files were split by concern.
52
+ - Security (dependabot #182, #183): dev-scope lockfile bumps only — @modelcontextprotocol/server 2.2.0 → 2.3.0 (fixes GHSA-46jr-4wx8-v453, GHSA-jm63-cq47-j5jp, GHSA-rvq5-wwqv-78pq) and vite 8.3.1 → 8.3.4 (fixes GHSA-587h-7wmj-h5pm, GHSA-698v-4r3x-3vcv, GHSA-r825-w8mm-5pxw). No runtime surface change (the package has no runtime dependencies).
53
+
10
54
  ## [0.21.0] - 2026-10-21
11
55
 
12
56
  **Structured bag properties, core ops in the catalog, op-key error text (issues #162, #163, #164, #177, #178, #179, #180, #181).** The plan hash moves to `baf3e855956e` (MINOR) — plan-pinned consumers must re-pin.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "generatedBy": "scripts/build-tool-catalog.mjs",
3
- "generatedAt": "2026-10-20T21:35:51.338Z",
4
- "planHash": "baf3e855956ed5450e1efea640845cb0fbc3a3a5a631a6159d4702b8dc726170",
3
+ "generatedAt": "2026-10-23T03:34:45.957Z",
4
+ "planHash": "479d7826acf548a8713c6891fe6cc5ea6ebb7c287896c932aac70834860a206f",
5
5
  "metaTools": [
6
6
  {
7
7
  "name": "autotask_catalog",
@@ -43,7 +43,7 @@
43
43
  {
44
44
  "name": "autotask_read",
45
45
  "title": "Read One Registry Operation (read mode)",
46
- "description": "Route an effect=read registry operation by its exact name (never fuzzy, never a tool name): the 363 field/entity/UDF-info reads (getEntityInfo/getFieldInfo/getUdfInfo) have no dedicated tool and ride this one, and any read op is routable. For field discovery use operations.fieldCatalog (compact) instead of per-resource getFieldInfo (full dump). Read-only by construction — every op it can route is effect=read (zero mutating requests), and a write or delete op is refused BEFORE any request (cross-mode refusal; the message names the correct tool). Reads have no dry run (a read with dry_run: true is refused). Harness approval policy: approve every tool with readOnlyHint !== true (exactly 254 today); the 601 readOnlyHint: true tools — including this one — are safe ungated; the 55 destructiveHint: true tools (54 dedicated delete tools + autotask_delete) require confirm === the operation key. Migration: autotask_invoke is REPLACED by autotask_read/autotask_write/autotask_delete — update harness config. For query/count ops args[0] is a params object (filter/limit); call autotask_describe first for the exact arg shape and filter grammar. The argument set is CLOSED at the top level: an unknown argument is refused before any request, with a typed error naming the key and the valid keys.",
46
+ "description": "Route an effect=read registry operation by its exact name (never fuzzy, never a tool name): the 363 field/entity/UDF-info reads (getEntityInfo/getFieldInfo/getUdfInfo) have no dedicated tool and ride this one, and any read op is routable. For field discovery use operations.fieldCatalog (compact) instead of per-resource getFieldInfo (full dump). Read-only by construction — every op it can route is effect=read (zero mutating requests), and a write or delete op is refused BEFORE any request (cross-mode refusal; the message names the correct tool). Reads have no dry run (a read with dry_run: true is refused). Harness approval policy: approve every tool with readOnlyHint !== true (exactly 256 today); the 603 readOnlyHint: true tools — including this one — are safe ungated; the 57 destructiveHint: true tools (56 dedicated delete tools + autotask_delete) require confirm === the operation key. Migration: autotask_invoke is REPLACED by autotask_read/autotask_write/autotask_delete — update harness config. For query/count ops args[0] is a params object (filter/limit); call autotask_describe first for the exact arg shape and filter grammar. The argument set is CLOSED at the top level: an unknown argument is refused before any request, with a typed error naming the key and the valid keys.",
47
47
  "input": {
48
48
  "operation": {
49
49
  "type": "string",
@@ -63,7 +63,7 @@
63
63
  {
64
64
  "name": "autotask_write",
65
65
  "title": "Write One Registry Operation (write mode)",
66
- "description": "Route an effect=write registry operation by its exact name (never fuzzy, never a tool name), dry-run-first: the first call always travels dry_run: true (the pinned DryRunResult envelope, simulated: true — the mutating request is never issued); pass dry_run: false to apply. A read or delete op is refused BEFORE any request (cross-mode refusal; the message names the correct tool). The 3 approval-gated writes (configuration_items.moveConfigurationItem, contacts.moveToCompany, resources.transferOwnership) additionally require confirm === the operation key, in BOTH dry-run and apply mode. Harness approval policy: this tool is one of the 254 tools that require approval (readOnlyHint !== true); the 601 readOnlyHint: true tools are safe ungated, and the 55 destructiveHint: true tools (54 dedicated delete tools + autotask_delete) require confirm === the operation key. Migration: autotask_invoke is REPLACED by autotask_read/autotask_write/autotask_delete — update harness config. For query/count ops args[0] is a params object (filter/limit); call autotask_describe first for the exact arg shape and filter grammar. The argument set is CLOSED at the top level: an unknown argument is refused before any request, with a typed error naming the key and the valid keys.",
66
+ "description": "Route an effect=write registry operation by its exact name (never fuzzy, never a tool name), dry-run-first: the first call always travels dry_run: true (the pinned DryRunResult envelope, simulated: true — the mutating request is never issued); pass dry_run: false to apply. A read or delete op is refused BEFORE any request (cross-mode refusal; the message names the correct tool). The 3 approval-gated writes (configuration_items.moveConfigurationItem, contacts.moveToCompany, resources.transferOwnership) additionally require confirm === the operation key, in BOTH dry-run and apply mode. Harness approval policy: this tool is one of the 256 tools that require approval (readOnlyHint !== true); the 603 readOnlyHint: true tools are safe ungated, and the 57 destructiveHint: true tools (56 dedicated delete tools + autotask_delete) require confirm === the operation key. Migration: autotask_invoke is REPLACED by autotask_read/autotask_write/autotask_delete — update harness config. For query/count ops args[0] is a params object (filter/limit); call autotask_describe first for the exact arg shape and filter grammar. The argument set is CLOSED at the top level: an unknown argument is refused before any request, with a typed error naming the key and the valid keys.",
67
67
  "input": {
68
68
  "operation": {
69
69
  "type": "string",
@@ -93,7 +93,7 @@
93
93
  {
94
94
  "name": "autotask_delete",
95
95
  "title": "Delete One Registry Operation (delete mode)",
96
- "description": "Route an effect=destructive (delete) registry operation by its exact name (never fuzzy, never a tool name), dry-run-first: the first call always travels dry_run: true (the pinned DryRunResult envelope, simulated: true); pass dry_run: false to apply. confirm === the operation key is REQUIRED for every delete (all 54), in BOTH dry-run and apply mode — the deliberate act is the call, not the delete. A read or write op is refused BEFORE any request (cross-mode refusal; the message names the correct tool). Harness approval policy: this tool is one of the 55 destructiveHint: true tools that require approval + confirm; approve every tool with readOnlyHint !== true (exactly 254 today); the 601 readOnlyHint: true tools are safe ungated. Migration: autotask_invoke is REPLACED by autotask_read/autotask_write/autotask_delete — update harness config. The argument set is CLOSED at the top level: an unknown argument is refused before any request, with a typed error naming the key and the valid keys.",
96
+ "description": "Route an effect=destructive (delete) registry operation by its exact name (never fuzzy, never a tool name), dry-run-first: the first call always travels dry_run: true (the pinned DryRunResult envelope, simulated: true); pass dry_run: false to apply. confirm === the operation key is REQUIRED for every delete (all 56), in BOTH dry-run and apply mode — the deliberate act is the call, not the delete. A read or write op is refused BEFORE any request (cross-mode refusal; the message names the correct tool). Harness approval policy: this tool is one of the 57 destructiveHint: true tools that require approval + confirm; approve every tool with readOnlyHint !== true (exactly 256 today); the 603 readOnlyHint: true tools are safe ungated. Migration: autotask_invoke is REPLACED by autotask_read/autotask_write/autotask_delete — update harness config. The argument set is CLOSED at the top level: an unknown argument is refused before any request, with a typed error naming the key and the valid keys.",
97
97
  "input": {
98
98
  "operation": {
99
99
  "type": "string",
@@ -10784,6 +10784,26 @@
10784
10784
  "reachReason": null,
10785
10785
  "boundedAlternative": null
10786
10786
  },
10787
+ {
10788
+ "operation": "time_entries.moveToTicket",
10789
+ "core": false,
10790
+ "tool": "autotask_timeEntry_move_to_ticket",
10791
+ "purpose": "Move a time entry to another ticket. The vendor update does NOT change ticketID, so this creates the record on the target ticket, verifies it, then deletes the source — with refusals for billed/approved/old sources and a twin check against duplicates.",
10792
+ "preferredWhen": "Re-parenting a time entry to another ticket. Not update: the vendor ignores ticketID there. When a move is refused or the source cannot be deleted, the result carries a manualAction with a clickable Autotask UI link (the record itself where the UI supports it, otherwise its parent ticket).",
10793
+ "backingOperation": "time_entries.moveToTicket",
10794
+ "effect": "destructive",
10795
+ "dryRunFirst": true,
10796
+ "confirm": true,
10797
+ "requiredArgs": [
10798
+ "sourceId",
10799
+ "toTicketId"
10800
+ ],
10801
+ "tier": "extended",
10802
+ "reachable": true,
10803
+ "reach": "dedicated tool",
10804
+ "reachReason": null,
10805
+ "boundedAlternative": null
10806
+ },
10787
10807
  {
10788
10808
  "operation": "time_entries.postedWithin",
10789
10809
  "core": false,
@@ -10821,6 +10841,27 @@
10821
10841
  "reachReason": null,
10822
10842
  "boundedAlternative": null
10823
10843
  },
10844
+ {
10845
+ "operation": "time_entries.recentTickets",
10846
+ "core": false,
10847
+ "tool": "autotask_timeEntry_recent_tickets",
10848
+ "purpose": "List the distinct tickets a resource logged time against in an inclusive dateWorked window, with entry count, total hours, last worked date and the ticket's number, title, status, assignee and company.",
10849
+ "preferredWhen": "Finding the ticket a piece of work was logged against, or which tickets a resource has been working on lately. Not for a total over many groups (operations.aggregate) or the raw entries (time_entries.dateWorkedWindow).",
10850
+ "backingOperation": "time_entries.recentTickets",
10851
+ "effect": "read",
10852
+ "dryRunFirst": false,
10853
+ "confirm": false,
10854
+ "requiredArgs": [
10855
+ "from",
10856
+ "to",
10857
+ "opts"
10858
+ ],
10859
+ "tier": "extended",
10860
+ "reachable": true,
10861
+ "reach": "dedicated tool",
10862
+ "reachReason": null,
10863
+ "boundedAlternative": null
10864
+ },
10824
10865
  {
10825
10866
  "operation": "time_entries.resolve",
10826
10867
  "core": false,
@@ -10862,7 +10903,7 @@
10862
10903
  "core": false,
10863
10904
  "tool": "autotask_timeEntry_update",
10864
10905
  "purpose": "Update a time entry.",
10865
- "preferredWhen": "Preferred over create or delete when the record exists and only some fields change.",
10906
+ "preferredWhen": "Preferred over create or delete when the record exists and only some fields change. Not for ticketID: the vendor ignores it on update; use time_entries.moveToTicket.",
10866
10907
  "backingOperation": "time_entries.update",
10867
10908
  "effect": "write",
10868
10909
  "dryRunFirst": true,
@@ -20827,6 +20868,26 @@
20827
20868
  "reachReason": "no dedicated tool — the op is still discoverable and invocable by exact name",
20828
20869
  "boundedAlternative": null
20829
20870
  },
20871
+ {
20872
+ "operation": "ticket_charges.moveToTicket",
20873
+ "core": false,
20874
+ "tool": "autotask_ticketCharge_move_to_ticket",
20875
+ "purpose": "Move a ticket charge to another ticket. The vendor update does NOT change ticketID, so this creates the record on the target ticket, verifies it, then deletes the source — with refusals for billed/approved/old sources and a twin check against duplicates.",
20876
+ "preferredWhen": "Re-parenting a ticket charge to another ticket. Not update: the vendor ignores ticketID there. When a move is refused or the source cannot be deleted, the result carries a manualAction with a clickable Autotask UI link (the record itself where the UI supports it, otherwise its parent ticket).",
20877
+ "backingOperation": "ticket_charges.moveToTicket",
20878
+ "effect": "destructive",
20879
+ "dryRunFirst": true,
20880
+ "confirm": true,
20881
+ "requiredArgs": [
20882
+ "sourceId",
20883
+ "toTicketId"
20884
+ ],
20885
+ "tier": "extended",
20886
+ "reachable": true,
20887
+ "reach": "dedicated tool",
20888
+ "reachReason": null,
20889
+ "boundedAlternative": null
20890
+ },
20830
20891
  {
20831
20892
  "operation": "ticket_charges.query",
20832
20893
  "core": false,
@@ -20887,7 +20948,7 @@
20887
20948
  "core": false,
20888
20949
  "tool": "autotask_ticketCharge_update",
20889
20950
  "purpose": "Update a ticket charge.",
20890
- "preferredWhen": "Preferred over create or delete when the record exists and only some fields change.",
20951
+ "preferredWhen": "Preferred over create or delete when the record exists and only some fields change. Not for ticketID: use ticket_charges.moveToTicket.",
20891
20952
  "backingOperation": "ticket_charges.update",
20892
20953
  "effect": "write",
20893
20954
  "dryRunFirst": true,
@@ -22281,13 +22342,32 @@
22281
22342
  "reach": "dedicated tool",
22282
22343
  "reachReason": null,
22283
22344
  "boundedAlternative": null
22345
+ },
22346
+ {
22347
+ "operation": "operations.uiLink",
22348
+ "core": false,
22349
+ "tool": "autotask_ui_link",
22350
+ "purpose": "Build a clickable Autotask UI deep link for a record in one ZERO-wire call, e.g. for the time entry or ticket a response is about.",
22351
+ "preferredWhen": "When a person must open a record in the Autotask UI (to finish an action the API will not execute, or to inspect a record). Pure string building — it never confirms the record exists.",
22352
+ "backingOperation": "operations.uiLink",
22353
+ "effect": "read",
22354
+ "dryRunFirst": false,
22355
+ "confirm": false,
22356
+ "requiredArgs": [
22357
+ "input"
22358
+ ],
22359
+ "tier": "extended",
22360
+ "reachable": true,
22361
+ "reach": "dedicated tool",
22362
+ "reachReason": null,
22363
+ "boundedAlternative": null
22284
22364
  }
22285
22365
  ],
22286
22366
  "counts": {
22287
- "registryOperations": 1213,
22367
+ "registryOperations": 1217,
22288
22368
  "coreOperations": 10,
22289
- "catalogRows": 1213,
22290
- "withTool": 850,
22369
+ "catalogRows": 1217,
22370
+ "withTool": 854,
22291
22371
  "readModeOnly": 363,
22292
22372
  "neverExposed": 0,
22293
22373
  "metaTools": 5