Skip to content

Integrate desktop authoring line into core TMCP#474

Draft
mattcfilbert wants to merge 54 commits into
mainfrom
feature/desktop
Draft

Integrate desktop authoring line into core TMCP#474
mattcfilbert wants to merge 54 commits into
mainfrom
feature/desktop

Conversation

@mattcfilbert

Copy link
Copy Markdown
Contributor

Description

AI Assisted Authoring via MCP is coming to Tableau Desktop.

  • Conflicts resolved keeping main's build infra (tsconfig.providers step, toolName registry, feature-gate layout) and folding in desktop additions (desktop asset staging, desktop agent-check checks, README sections)
  • All 45 desktop tools carry the Required contract; hints chosen per tool semantics (execute-tableau-command destructive, cache mutators non-idempotent, readers read-only/idempotent)
  • Serialized desktop tools/list surface fits the 46k deferral-cliff budget (45,572 measured) via schema-description compression only; tool descriptions and expertise:// steering pointers unchanged from authoring
  • CI: desktop variant build step added; workflow also triggers on PRs to feature/desktop

Type of Change

  • Bug fix
  • New feature
  • Breaking change
  • Documentation update
  • Other (please describe):

How Has This Been Tested?

  • Validation: tsc clean, 255 test files / 3660 passed, lint clean, web + desktop builds green

Checklist

  • I have updated the version in the package.json file by using npm run version. For example,
    use npm run version:patch for a patch version bump.
  • I have made any necessary changes to the documentation
  • I have added tests that prove my fix is effective or that my feature works
  • New and existing unit tests pass locally with my changes
  • I have documented any breaking changes in the PR description. For example, renaming a config
    environment variable or changing its default value.

Contributor Agreement

By submitting this pull request, I confirm that:

…ng at 1e81b39)

Squash-and-diff of the desktop/authoring tool line (45 tools, knowledge corpus,
binder/template system, validation rules) onto main v2.25.0.

- Conflicts resolved keeping main's build infra (tsconfig.providers step,
  toolName registry, feature-gate layout) and folding in desktop additions
  (desktop asset staging, desktop agent-check checks, README sections)
- All 45 desktop tools carry the Required<ToolAnnotations> contract; hints
  chosen per tool semantics (execute-tableau-command destructive, cache
  mutators non-idempotent, readers read-only/idempotent)
- Serialized desktop tools/list surface fits the 46k deferral-cliff budget
  (45,572 measured) via schema-description compression only; tool
  descriptions and expertise:// steering pointers unchanged from authoring
- CI: desktop variant build step added; workflow also triggers on PRs to
  feature/desktop
- Validation: tsc clean, 255 test files / 3660 passed, lint clean, web +
  desktop builds green
@mattcfilbert
mattcfilbert marked this pull request as draft July 9, 2026 15:19

@tableaukyler tableaukyler left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Early review — desktop authoring integration

Solid, well-tested port with a clean validation/binder architecture. Two confirmed correctness bugs in the field-metadata apply path should block; a handful of important robustness risks in the network/apply paths are worth confirming before this leaves draft. Scope: I reviewed the ~12.6k lines of desktop-authoring TypeScript logic (skipped knowledge markdown, XML fixtures, generated data, tests).

🔴 Blocking

  • fields.ts encoding-add verification checks the wrong ref — throws on real success (calc-field aggregation case)
  • fields.ts ensureColumnInstanceInDependencies returns the pre-correction name — emits a dangling field binding

🟠 Important

  • externalApiClient per-request timeout is dead in the production path
  • interpretLoadOutcome payload-sniffing can convert real successes into errors
  • classify geo-slot widening can silently double-bind one field (fail-open)
  • field-resolver count-distinct emits an invalid derivation

🟡 Nits — duplicated aggregation logic (root cause of the two blockers), a comment that misdescribes the function below it, stale // src/binder/... header paths, WHAT-narration comments + leftover [DEBUG] logs, and a couple of fail-closed allowlists that hard-error on unlisted-but-valid content.

Great test coverage and the fail-closed security funnel (pack verification, SEA asset integrity, apply mutex) all checked out clean.

🤖 Posted by KylerGPT — an AI reviewer trained on Kyler's review history. Kyler reviewed and approved this before posting.

const verifyPanes = normalizeArray(worksheet.table.panes.pane);
const verifyPane = verifyPanes[0];
const verifyEncodings = normalizeArray(verifyPane?.encodings?.[encodingType]);
const encodingAdded = verifyEncodings.some((enc: any) => enc['@_column'] === columnRef);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Verification checks the wrong ref — throws on real success. The encoding was inserted with correctedColumnRef (line 145), but this verifies against the original columnRef. For any calc field whose formula already aggregates (the exact case the correction exists for), correctedColumnRef !== columnRef, so encodingAdded is false and this throws Failed to add encoding... on a successful add. Verify against correctedColumnRef.

dependencies.length === 1 ? dependencies[0] : dependencies;

// Return the corrected instance name so caller can use it
return correctedInstanceName;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Returns the pre-correction name — dangling field binding. When the base column isn't yet in datasource-dependencies (only in the workbook), the first correction block (~654) is skipped and the second block (~814) creates the instance as actualColumnInstanceName ([usr:...]) — but this returns correctedInstanceName ([sum:...]). The caller writes the returned name onto the shelf/encoding, so it references a column-instance that doesn't exist. Return actualColumnInstanceName (and ideally collapse the two correction blocks into one path).

headers['content-type'] = options.contentType;
}

const signal = options.signal ?? AbortSignal.timeout(this.timeoutMs);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 Per-request timeout is dead in the production path. options.signal ?? AbortSignal.timeout(...) means the 60s floor only applies when no signal is passed — but ExternalApiToolExecutor.callEndpoint always threads a signal, so the timeout never engages. If the caller signal carries no timeout, a hung loopback socket leaves fetch outstanding indefinitely. Combine them: AbortSignal.any([options.signal, AbortSignal.timeout(this.timeoutMs)]) so a caller signal augments rather than replaces the floor.

* so a normal success (empty `result`, no `error`) is never turned into a false
* negative.
*/
export function interpretLoadOutcome(

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 Payload-sniffing can convert real successes into errors. This now gates the agent-API load success paths. Branch 1 (top-level status.error) is safe, but branches 2-4 key off generic field names (status, error, errors, success/ok/loaded) inside an arbitrary result payload the code doesn't control. A successful load that happens to carry one of those fields with unrelated semantics gets relayed as load-rejected. Worth confirming against real Desktop payloads before trusting the sniffing branches.

}

const chosen = [...picks.values()];
if (new Set(chosen).size !== chosen.length) return null; // two slots, one field

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 Geo-slot widening can silently double-bind one field (fail-open). This distinctness check compares geo picks only among themselves, never against fields already consumed by non-geo slots in used. The W60 zero-candidate widening (~887) resolves a geo slot over the full schemaDims. For a fast-path template with ≥2 geo slots plus another dimension slot, an ask naming both can bind the same field to a geo slot AND a non-geo slot, and validation passes — a silent wrong bind on a path whose contract is "fail closed." Fix: exclude used fields from the widened pool, or fold geo picks into a distinctness check that also includes the non-geo bound fields.

// If it's a calculated field with aggregation, we need to use usr prefix
if (existingBaseColumn?.calculation?.['@_formula']) {
const formula = existingBaseColumn.calculation['@_formula'];
const aggFunctions = [

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Duplicated algorithm (root cause of the two blockers above). This 13-element aggFunctions array + hasAggregation scan + usr: correction is copy-pasted verbatim again at ~817, and the quantitative-derivation list is duplicated twice more in this function (plus a third aggFunctions copy in field-builder.ts:387). Changing the recognized aggregation set means editing all copies in lockstep. Extract formulaHasAggregation(formula) and a shared QUANTITATIVE_DERIVATIONS set.

import { rewriteFieldReferences } from './fieldReferenceRewriter.js';
import { injectTemplate, InsertPosition, SheetType } from './injectTemplate.js';

/** Escape the five XML metacharacters (identical to the inject-template tool). */

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Comment misdescribes the function below it. This doc (Escape the five XML metacharacters...) sits directly above ensureUserNamespace; the function it actually describes (escapeXml) is further down with no doc. The two ported comments got interleaved — move it (or drop it per the NO-COMMENTS rule).

@@ -0,0 +1,1260 @@
// src/binder/classify.ts

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Stale header path. Reads // src/binder/classify.ts but the tree moved under desktop/. Same stale first-line comment is on all 11 src/desktop/binder/*.ts files — per the delete-stale-comments rule, drop them.

});
const doc = parser.parseFromString(workbookXml.trim(), 'text/xml');

// Find the <window class="dashboard" name="<dashboardName>"> element

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 WHAT-narration comments. These (// Find the <window..., // Remove any existing <viewpoints>, // Build new <viewpoints>) restate what the next line does — flagged under the near-absolute NO-COMMENTS rule. The function's leading docblock explaining why viewpoints matter is the allowed kind and should stay. (Same applies to leftover [DEBUG] console.error/console.warn in the fields.ts apply path and the Lane M6 milestone... PR-status headers across the intelligence files.)

* `column-instance` derivation outside this closed set silently rewrites to None on
* load. Exported so other code can share the single allowlist.
*/
export const CANONICAL_DERIVATIONS = new Set<string>([

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Fail-closed allowlist that hard-ERRORs on unlisted-but-valid content. Any derivation outside CANONICAL_DERIVATIONS becomes a blocking error — e.g. spatial Collect (MakePoint/MakeLine) isn't listed, so a legit live-readback workbook containing it would be hard-rejected as "invalid derivation." An allowlist that errors is fragile to omissions; consider warn-not-error for the "unknown but not obviously invalid" case. Same pattern in connectionsNotAuthorable.ts (terminal reject on any non-federated connection). Both are documented as intentional — flagging the fragility, not demanding a change.

@tableaukyler tableaukyler left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(duplicate of the review directly above — double-posted by mistake; see the other review for the full findings)

mattcfilbert and others added 26 commits July 15, 2026 00:45
…play, post-#501) (#504)

* Resync feature/authoring → feature/desktop (content-level replay of 1e81b39..b537e4d)

The af82a71 squash broke ancestry, so this is the planned one-time
content-level resync, not a merge: git apply -3 of the authoring delta
since the squash point (1e81b39b537e4d, post-#501) onto
feature/desktop. 361 files: the full W60-W64 authoring evolution — P0
dogfood fixes (spatial-intent guard, bindExplicitTemplate manifest
enforcement, tooltip-dimension-requires-attr + 25-rule validation suite),
consolidated add-field/remove-field tools, knowledge corpus refresh,
render-stamp manifests, behavioral test suites.

Desktop-side work is preserved, adjudicated file-by-file against the
squash base:
- The 6 per-shelf field tools desktop edited in #431 are deleted (they
  were consolidated into add-field/remove-field on the authoring line);
  #431's Required destructiveHint/idempotentHint annotations are grafted
  onto the 8 authoring tools that lacked them, using desktop's reviewed
  hint values per tool.
- 24 region conflicts resolved to the authoring side after verifying no
  conflict block contained desktop-only annotation content.
- package.json keeps desktop's version (2.25.0); lockfile regenerated.
- Byte-ratchet reconciled: plan-dashboard-creation and inject-template
  descriptions trimmed to fit their grandfathered caps with the new
  annotation hints; stale pins ratcheted down (bind-template 2131→2110,
  validate-proposal 2035→2014, plan-dashboard-creation 2043→2040,
  inject-template 1404→1382).
- eslint now ignores the two lockstep byte-identity files (classify.ts,
  fieldReferenceRewriter.ts) whose deliberate prettier drift would
  otherwise fail desktop CI's lint gate; the other 9 pre-existing lint
  errors from the authoring line are fixed. scripts/check-lockstep.mjs
  still passes (byte identity intact).

Validation: full combined suite 4201 passed / 1 skipped (289 files),
tsc --noEmit clean, npm run lint clean, lockstep gate OK, desktop
tools/list surface tests green.

Note: tmcp PR #503 (detail→lod normalization) is open against
feature/authoring and is NOT in this snapshot — merge it there and
re-apply the 3-file delta here, or retarget it to feature/desktop after
this lands.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(web): pin the clock in stale-content report test (UTC-midnight flake)

The 'runs a single Site Content VDS query' test computed daysSinceLastUse
against the real clock while wb-recent's fixture date is fixed
(2026-04-15) — the moment UTC passed 2026-07-15 the row aged past the
90-day threshold and the test started failing everywhere. Pin Date (only
Date, so awaited promises still run) to the same 2026-05-20 anchor the
file's unit tests already use.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(binder): 30s timeout on the memo speedup benchmark (CI-runner flake)

The warm-vs-cold benchmark (300 cold binds over a 302-field workbook)
blew the 5s default on the shared CI runner under the combined suite's
load (both node jobs, 2026-07-15 run). The assertion is a load-immune
ratio (warm < cold), so a generous timeout is safe.

Port note: bump a2td's equivalent memo benchmark the same way if it
starts flaking there.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(e2e): isolate seaAssets from the parallel e2e pool (build/ race killed 12 suites)

Root cause of the 12 e2e suites dying with "MCP error -32000: Connection
closed" at ~70ms in CI: seaAssets.test.ts (new to this branch from the
authoring line) runs buildVariant('desktop') — an in-place rebuild of
./build — concurrently with the rest of the e2e pool, whose suites spawn
`node build/index.js` children. A child that boots during the write
window dies pre-handshake; which 12 suites lose is scheduling luck.
Evidence: an unrelated branch without this file ran the same pipeline
green 15 minutes before the red run; the failing suites' code and the
web runtime are byte-identical to feature/desktop/main.

Fix: exclude seaAssets from vitest.config.e2e.ts and run it alone via
vitest.config.e2e.sea.ts (npm run test:e2e:sea) as a follow-up step in
the run-e2e-tests composite action. mergeConfig CONCATENATES include
arrays, so the sea config hard-overrides test.include after merging.

Verified: sea-only config runs exactly seaAssets (2 tests green,
desktop-variant build included); main e2e config lists 0 seaAssets
tests; eslint + tsc clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* TEMP: e2e child-stderr diagnostic (redacted) — revert before merge

* Revert "TEMP: e2e child-stderr diagnostic (redacted) — revert before merge"

This reverts commit 34fd0b1.

* Revert "test(e2e): isolate seaAssets from the parallel e2e pool (build/ race killed 12 suites)"

This reverts commit c403eaf.

* fix(ci): build:desktop must not clean away build/index.js before E2E

Root cause of the 11-12 e2e suites failing with "MCP error -32000:
Connection closed" at ~70ms on this branch: the desktop-branch ci.yml
runs "Build desktop variant" AFTER "Build", and plain build:desktop
CLEANS ./build (src/scripts/build.ts rm's it when --dirty is absent) —
deleting the build/index.js every e2e suite's spawned child needs. Suites
scheduled before server.test.ts incidentally rebuilds it (buildVariant
--dirty) died instantly; later suites passed — which is why the failing
set looked arbitrary but was deterministic per run.

Receipts: locally, `npm run build && npm run build:desktop` leaves NO
build/index.js; with --dirty both artifacts coexist. A CI diagnostic
(temporarily committed, now reverted) showed an identically-env'd child
booting cleanly — env and runtime were never the problem. The earlier
seaAssets-isolation hypothesis is reverted as refuted.

Fix: new build:desktop:dirty script; ci.yml uses it. The clean behavior
of plain build:desktop is preserved for standalone use.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(ci): use build:desktop:dirty in the workflow (companion to 416ecc2 — the Edit was hook-blocked so the yml change landed separately)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
…tools under the 46k cliff (#505)

* Resync feature/authoring → feature/desktop (content-level replay of 1e81b39..b537e4d)

The af82a71 squash broke ancestry, so this is the planned one-time
content-level resync, not a merge: git apply -3 of the authoring delta
since the squash point (1e81b39b537e4d, post-#501) onto
feature/desktop. 361 files: the full W60-W64 authoring evolution — P0
dogfood fixes (spatial-intent guard, bindExplicitTemplate manifest
enforcement, tooltip-dimension-requires-attr + 25-rule validation suite),
consolidated add-field/remove-field tools, knowledge corpus refresh,
render-stamp manifests, behavioral test suites.

Desktop-side work is preserved, adjudicated file-by-file against the
squash base:
- The 6 per-shelf field tools desktop edited in #431 are deleted (they
  were consolidated into add-field/remove-field on the authoring line);
  #431's Required destructiveHint/idempotentHint annotations are grafted
  onto the 8 authoring tools that lacked them, using desktop's reviewed
  hint values per tool.
- 24 region conflicts resolved to the authoring side after verifying no
  conflict block contained desktop-only annotation content.
- package.json keeps desktop's version (2.25.0); lockfile regenerated.
- Byte-ratchet reconciled: plan-dashboard-creation and inject-template
  descriptions trimmed to fit their grandfathered caps with the new
  annotation hints; stale pins ratcheted down (bind-template 2131→2110,
  validate-proposal 2035→2014, plan-dashboard-creation 2043→2040,
  inject-template 1404→1382).
- eslint now ignores the two lockstep byte-identity files (classify.ts,
  fieldReferenceRewriter.ts) whose deliberate prettier drift would
  otherwise fail desktop CI's lint gate; the other 9 pre-existing lint
  errors from the authoring line are fixed. scripts/check-lockstep.mjs
  still passes (byte identity intact).

Validation: full combined suite 4201 passed / 1 skipped (289 files),
tsc --noEmit clean, npm run lint clean, lockstep gate OK, desktop
tools/list surface tests green.

Note: tmcp PR #503 (detail→lod normalization) is open against
feature/authoring and is NOT in this snapshot — merge it there and
re-apply the 3-file delta here, or retarget it to feature/desktop after
this lands.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(web): pin the clock in stale-content report test (UTC-midnight flake)

The 'runs a single Site Content VDS query' test computed daysSinceLastUse
against the real clock while wb-recent's fixture date is fixed
(2026-04-15) — the moment UTC passed 2026-07-15 the row aged past the
90-day threshold and the test started failing everywhere. Pin Date (only
Date, so awaited promises still run) to the same 2026-05-20 anchor the
file's unit tests already use.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(combined): TOOL_PROFILE=combined-lean — full desktop surface + lazy web tools under the 46k cliff

The combined desktop+web build serializes ~140.9k bytes of tools/list —
3x past the ~46,000-byte ToolSearch auto-deferral cliff — and even
dropping the whole pulse group (36.4k) leaves it far over, so no eager
subset fits. combined-lean keeps the full desktop authoring surface
eager (44,015 bytes, unchanged) and collapses the entire web surface
into ONE ~600-byte loader tool.

- TOOL_PROFILE moves from desktop config to BaseConfig (shared across
  web/desktop/combined; same trim+lowercase normalization). Desktop
  treats 'combined-lean' as 'full' — the lean half is the web side.
- New web tool `load-web-tools {group}` (Tony's lazy-load): calling it
  registers the requested web tool group's real tools on the live server
  via the same filtered pipeline as eager startup (disabled +
  INCLUDE_TOOLS/EXCLUDE_TOOLS still apply); the SDK emits
  notifications/tools/list_changed per registration so clients pick up
  e.g. the 7 pulse tools on demand. Idempotent per group; 'all' loads
  everything. Unset/'full' TOOL_PROFILE keeps today's eager behavior
  byte-identical.
- registerTools callback wiring extracted to _registerWebTool (no
  behavior change on the eager path).
- New suite server.combined-lean.test.ts: combined-lean registers
  desktop-full + exactly the loader; total serialized surface asserted
  <= 46,000 (same serialization as the desktop budget test); loader
  entry capped at 650 bytes; pulse group loads on demand and is
  idempotent; EXCLUDE_TOOLS respected on hydration; unset profile
  unchanged.

Caveat: lazy loading assumes the client honors tools/list_changed (or
re-lists after calling the loader) — true for the stdio/combined embed
path this profile targets; stateless HTTP keeps eager registration.

Validation: full suite 4207 passed / 1 skipped (290 files), tsc clean,
lint clean, lockstep gate OK.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(combined-lean): stateless-HTTP eager fallback, serialized loads, honest budget accounting

Three findings from adversarial review of the combined-lean feature:

1. Stateless HTTP (TRANSPORT=http + DISABLE_SESSION_MANAGEMENT) discards
   the per-request server as the response closes, so lazily hydrated
   tools would register on a corpse. combined-lean now falls back to
   eager registration there; the lean surface applies only where the
   server outlives the call (stdio / sessionful HTTP).

2. Overlapping load-web-tools calls could both pass the loaded-set check
   and the second registerTool would throw on the duplicate name.
   Loads are now serialized through a never-rejecting promise chain;
   concurrent same-group calls resolve as loaded + already-loaded with
   exactly one registration set.

3. The 46k budget test summed per-entry bytes, undercounting the actual
   {"tools":[...]} payload envelope (~54 bytes) — real bytes sat at
   exactly 46,000 with a passing assert. The test now measures the
   payload shape itself, and the loader description is trimmed to buy
   real margin.

Suite: 4209 passed / 1 skipped, tsc + lint clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(binder): 30s timeout on the memo speedup benchmark (CI-runner flake)

The warm-vs-cold benchmark (300 cold binds over a 302-field workbook)
blew the 5s default on the shared CI runner under the combined suite's
load (both node jobs, 2026-07-15 run). The assertion is a load-immune
ratio (warm < cold), so a generous timeout is safe.

Port note: bump a2td's equivalent memo benchmark the same way if it
starts flaking there.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(e2e): isolate seaAssets from the parallel e2e pool (build/ race killed 12 suites)

Root cause of the 12 e2e suites dying with "MCP error -32000: Connection
closed" at ~70ms in CI: seaAssets.test.ts (new to this branch from the
authoring line) runs buildVariant('desktop') — an in-place rebuild of
./build — concurrently with the rest of the e2e pool, whose suites spawn
`node build/index.js` children. A child that boots during the write
window dies pre-handshake; which 12 suites lose is scheduling luck.
Evidence: an unrelated branch without this file ran the same pipeline
green 15 minutes before the red run; the failing suites' code and the
web runtime are byte-identical to feature/desktop/main.

Fix: exclude seaAssets from vitest.config.e2e.ts and run it alone via
vitest.config.e2e.sea.ts (npm run test:e2e:sea) as a follow-up step in
the run-e2e-tests composite action. mergeConfig CONCATENATES include
arrays, so the sea config hard-overrides test.include after merging.

Verified: sea-only config runs exactly seaAssets (2 tests green,
desktop-variant build included); main e2e config lists 0 seaAssets
tests; eslint + tsc clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* TEMP: e2e child-stderr diagnostic (redacted) — revert before merge

* Revert "TEMP: e2e child-stderr diagnostic (redacted) — revert before merge"

This reverts commit 169eb9f.

* Revert "test(e2e): isolate seaAssets from the parallel e2e pool (build/ race killed 12 suites)"

This reverts commit 4e5b6ff.

* fix(ci): build:desktop must not clean away build/index.js before E2E

Root cause of the 11-12 e2e suites failing with "MCP error -32000:
Connection closed" at ~70ms on this branch: the desktop-branch ci.yml
runs "Build desktop variant" AFTER "Build", and plain build:desktop
CLEANS ./build (src/scripts/build.ts rm's it when --dirty is absent) —
deleting the build/index.js every e2e suite's spawned child needs. Suites
scheduled before server.test.ts incidentally rebuilds it (buildVariant
--dirty) died instantly; later suites passed — which is why the failing
set looked arbitrary but was deterministic per run.

Receipts: locally, `npm run build && npm run build:desktop` leaves NO
build/index.js; with --dirty both artifacts coexist. A CI diagnostic
(temporarily committed, now reverted) showed an identically-env'd child
booting cleanly — env and runtime were never the problem. The earlier
seaAssets-isolation hypothesis is reverted as refuted.

Fix: new build:desktop:dirty script; ci.yml uses it. The clean behavior
of plain build:desktop is preserved for standalone use.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(ci): use build:desktop:dirty in the workflow (companion to 416ecc2 — the Edit was hook-blocked so the yml change landed separately)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(lint): sort server.web.ts imports (simple-import-sort) — combined-lean import block

CI lint (24.x) flagged the WebToolGroupName/webToolGroupNames import ordering
in the combined-lean addition; local lint had a stale eslint cache. Autofix
only — reorders the type/value imports from ./tools/web/toolName.js.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
…ield-tool title scrub (desktop twin of #503) (#510)

fix(metadata): normalize detail→lod encoding + SQL string-fn knowledge + field-tool title scrub (desktop twin of #503)

Port of #503 (feature/authoring) onto feature/desktop, needed because the
#504 content resync snapshot predates #503:
- canonicalEncodingType() maps the "detail" alias → round-trip-stable <lod>
  at addFieldToEncoding/removeFieldFromEncoding/moveFieldInEncoding entry
  (W-23447710 follow-up: Tableau silently strips <detail> on apply — the
  Location pill vanished and the symbol map collapsed to one AVG centroid)
- SQL string-functions translation section in tactics/data/sql-translation.md
- add-field/remove-field title + description tableau-speak scrub

Excluded from #503: package.json 2.12.1 bump (desktop line is 2.25.x →
bumped 2.25.1 instead); getStaleContentReport.test.ts fake-timers pin
(already present on this line).

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
…lockstep carrier) (#511)

* feat(classify): semantic-role-first geo slot binding (lockstep carrier for a2td W2)

Geo slot resolution prefers Tableau's semantic-role tag over name tokens: a
unique concept match ([State].[Name] → state slot) wins outright; 2+ matches
fail closed; no match falls back to the existing name-token unique-max logic,
except a field whose semantic role names a DIFFERENT geo concept is excluded
from name fallback. Adds city/zip concepts + the GEO_SEMANTIC_ROLE_CONCEPT
table, and an optional semanticRole on the classifier's inlined SchemaField.

BEHAVIOR IN THIS REPO TODAY: inert — desktop's schema summary does not yet
populate semanticRole, so every field falls through to the unchanged name-token
path (optional field, structural typing; compiles and binds identically).
The follow-up plumbing (listAvailableFields → SchemaField semanticRole, mirror
tables in the ask-router equivalent, tests) is the a2td→tmcp port ledger item;
a2td carries the full implementation and the behavioral test suite.

This file is the canonical lockstep-core copy: a2td's src/lockstep-core/
classify.ts is byte-synced FROM this file (sync-lockstep-core.mjs) — landing
this keeps the byte-identity invariant real rather than hash-regen-only.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(classify): masking hyphen↔space lockstep + masked propose-shortlist scoring (red-team CLS-001/002)

- maskFieldNames now applies the same hyphen↔space transform as
  phraseIndexInAsk, so a hyphenated field name matched via its spaced form
  ("Waterfall-Chart" ↔ "waterfall chart") is fully blanked — a FIELD NAME can
  no longer survive masking and select its chart family.
- buildLlmInput keyword-scores (and Fuse-searches) the MASKED ask, closing
  the same field-name leak on the propose leg (a field literally named "Pie"
  no longer collapses the shortlist to the pie family).

Canonical lockstep-core edit; a2td synced + hash-regenerated the same night
with pinning tests (portability-lane masking-hardening describe block).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
…→ feature/desktop (#512)

* feat(desktop): W2 semanticRole plumbing + W4 post-apply readback verification

W2 — plumb Tableau @_semantic-role from the metadata read path into
SchemaField.semanticRole (types.ts, field-builder.ts, schema-summary.ts) so
classify.ts's already-present geo semantic-role binder activates. Territory-class
fields ([State].[Name]) now bind spatial-choropleth-map end-to-end; verified no
wrong-bind against portability/bind-behavior-matrix/carrier-uniqueness suites.

W4 — port readback-verify.ts (verbatim from a2td) + wire into loadWorksheetXml:
after a successful apply, re-read the sheet and diff intent-bearing structures.
ERROR-severity drops (silently-dropped-pill) fail the apply via a new
readback-failed variant; WARNING-severity findings ride along on Ok and surface
in the apply-worksheet message. Readback runs before focus (never navigate to a
rejected sheet). Re-read failure skips verification, never masks a real apply.

Note: tmcp has no interaction/episode event system, so a2td's readback_verification
interaction event has no home here (logged via structured logger instead).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(desktop): W9 session reliability — stale-session gate + cross-instance cache-bleed guard

Freshness gate: SessionManager.getExecutor now re-verifies a cached local session's
Desktop instance against the live manifest (port + start_time) before handing the
executor back. A restarted/quit Desktop evicts the cached session and throws
SessionStaleError with agent-actionable recovery. DesktopDiscoverer injectable for test.

Cache-bleed guard: new cacheFingerprint.ts writes a <file>.meta.json sidecar recording
the producing Desktop instance (pid/port/start_time) when batch-create-and-cache-sheets
caches workbook/worksheet/dashboard XML; build-and-apply-worksheet and
build-and-apply-dashboard refuse a cache whose fingerprint mismatches the current
session (new CacheSessionMismatchError, 409) and tell the agent to re-read. No override
flag by design. Missing/unreadable sidecars and unresolvable fingerprints proceed
(pre-sidecar caches stay valid, never block blind).

Adapted to tmcp's session model: fingerprints resolve via DesktopDiscoverer (injectable
resolver) rather than a2td's ServerContext/episodeStore, which tmcp does not have.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(desktop): W6 sheet-name did-you-mean on a worksheet miss

getWorksheetXml's no-worksheet-found branches now append a best-effort suggestion:
list the live sheet names (via the list-worksheets command), surface close matches
(case-insensitive substring either direction) first, and tell the agent to ask the
user rather than guess when nothing clearly matches. Never throws; '' when the list is
unavailable (zero cost on the success path). This is the SHEET-name twin of tmcp's
existing FIELD did-you-mean (field-resolver.ts).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test(desktop): W1 + W3 portability-lane behavioral evidence

Adds src/desktop/binder/portability-lane.test.ts — offline bind assertions over three
non-Superstore fixtures (SaaS ops / healthcare claims / finance planning) that pin the
overnight behaviors tmcp's existing portability.invariant.test.ts does not cover:
  • W2 — Territory tagged [State].[Name] binds spatial-choropleth-map by semantic role,
    end-to-end, used_llm=false, non-Superstore datasource (regression lock for the metadata
    plumbing that activates classify.ts's geo binder).
  • W3 — the histogram refuses without live bin-width stats: distribution-histogram stays
    fast_path_eligible=false with the DATASET_SPECIFIC_FORMULA blocker, Call-1 proposes,
    an explicit proposal escalates, and even forced-eligible a bind never emits the old
    Superstore-derived 500 (tmcp never carried a2td's width adapter — this locks that absence).
  • W1 — non-Superstore field_mapping never leaks "Superstore"; each fixture binds under
    its own datasource name.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(lockstep): regenerate classify.ts hash to match the committed file (pre-existing base drift)

The lockstep hash gate was RED on origin/feature/desktop before this wave: PR #511
landed the semantic-role classify.ts (hashing 8f0dc7d…) but never regenerated
lockstep.hashes.json, which still recorded the pre-#511 hash 2fdf604…. classify.ts
itself is UNCHANGED by this port. The regenerated hash equals a2td's manifest entry
for src/lockstep-core/classify.ts exactly, restoring the cross-repo byte-lock invariant.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(desktop): lazy-construct DesktopDiscoverer in SessionManager (preserve explicit-session invariant)

The W9 freshness gate added a discoverer to SessionManager, but constructing it eagerly
in the ctor made `new DesktopMcpServer()` consult discovery machinery even for an
explicit-session tool call that never needs it — tripping the "discovery is never
consulted" invariant in bindTemplate/dashboardAutoApply tests. Build it lazily on first
real use (session create or freshness gate); an injected discoverer still wins for tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* style(desktop): eslint --fix formatting on the ported wave (prettier/quotes/import-sort)

Autofix-only: single-quote strings, prettier wrapping, and import sort on the W1/W2/W4/W6/W9
files. No behavior change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(desktop): wire W9 cache-bleed guard into the primary get→apply flow

The overnight port wired the cross-instance cache-fingerprint guard (W9) only
into the coordination/batch build paths (batchCreateAndCacheSheets,
buildAndApply{Worksheet,Dashboard}). The primary interactive flow — get-*-xml →
edit → apply-* — was unguarded on BOTH ends: the get tools wrote no sidecar and
the apply tools ran no check. a2td guards all three artifact types on this flow
(worksheet.ts:229/299, workbook.ts:294/369, dashboard.ts:159/227); this closes
the port-fidelity gap.

Producers now writeSidecar after caching: get-worksheet-xml, get-workbook-xml,
get-dashboard-xml. Consumers now checkSidecar in the file-mode branch before
applying (returning CacheSessionMismatchError on mismatch): apply-worksheet,
apply-workbook, apply-dashboard. Guard is file-mode only — inline content
carries no fingerprint. Added explicit session-mismatch tests for apply-workbook
and apply-dashboard (the guard was previously only covered on the fail-open path).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(desktop): W4 readback tolerates an Automatic mark resolved to a concrete class

Dual review (P1) flagged a false-positive: an authored <mark class="Automatic">
— the single most common authored mark — is resolved by Tableau to a concrete
class (Bar/Circle/Shape/…) on readback. The exact-match check treated that as a
changed/dropped mark at ERROR severity, which fails a legitimate apply and tells
the agent to "fix" correct XML. Now an intended Automatic mark is satisfied by
any concrete class in the same pane; only a truly absent mark (no candidate) is
still a real drop. Strictly a false-positive reducer — it never masks a genuine
drop (negative-control test pins that).

NOTE: the same exact-match logic exists in a2td's readback-verify.ts:298 — owed
as a cross-repo twin fix (port-debt ledger).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
…esty (desktop twin of a2td #207) (#514)

* fix(desktop): stamp fingerprint sidecars on all cache-mutator writes (SCG-001/002 twin)

write-cached-xml, add-field, remove-field, and inject-template wrote cache
files without fingerprint sidecars; a missing sidecar deliberately fails
open at apply, so files written by these tools permanently bypassed the
cross-instance cache-bleed guard. Stamp writeSidecar after every mutator
write and require session on their schemas (desktop twin of a2td #207).

inject-template's describe trims land it at 1356 bytes — pin ratcheted
down from 1382.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(desktop): geo concept check on the validate leg + semanticRole in LLM input (GEO-02/03 twin)

Gate 3b: a geo slot must not bind a field whose Tableau semantic role
names a DIFFERENT geo concept — the deterministic path (pickGeoField)
already respected semantic roles, but the LLM-propose path validated on
name affinity alone. Mirrors the private geo tables from hash-gated
classify.ts (kept token-identical, pinned by a source-level parity test)
and enriches buildLlmInput with semanticRole so the model sees the same
evidence the binder does. Desktop twin of a2td #207; lockstep-core bytes
untouched.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(desktop): honest response text when post-apply readback is skipped (RB-01/SCG-004 twin)

verifyPostApplyWorksheetReadback now returns {ok,status,message} instead
of a bare findings array, so a skipped readback (worksheet re-read failed
or threw) is distinguishable from a passed one. apply-worksheet and
build-and-apply-worksheet no longer claim verified success when the
readback never ran — the response carries the skip status and reason.
Desktop twin of a2td #207.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
… dimension without slot capacity (#515)

fix(binder): temporal completion must not swallow an ask-named dimension with no slot capacity (CLS-003)

Unique-date temporal completion ("trend of Actual Amount by Fiscal Period" with
one schema date) silently dropped the ask-named non-temporal dimension when the
template had no slot to consume it, charting a date axis the user never asked
for. roleGreedyBind's final-bind completion now fails closed (escalate to
propose/ask) when an ask-named non-temporal dimension exceeds remaining slot
capacity. Capacity counts remaining active categorical/geo slots PLUS one armed
optional facet slot — facetBinding appends a spare named categorical after the
required slots bind, so explicit facet asks ("trend of Sales per Region") still
complete. Guard runs only when temporalCompletion is present, so
selectWithinFamily's slot-fit probes are byte-unchanged.

Lockstep twin: a2td src/lockstep-core/classify.ts synced from this file in the
paired a2td PR.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
…/506) (#520)

* feat(validation): malformed-top-n-filter rule + worksheets.md Top-N correction (W-23447507 port)

Port of a2td #209's W-23447507 slice to feature/desktop. A Top-N filter
authored as a flat <groupfilter function="filter"> with count/direction
attributes is silently stripped by Tableau on apply — viz shows unfiltered
data while apply reports success. New preflight rule blocks the flat shape
in workbook + worksheet contexts; worksheets.md now teaches the confirmed
nested function="end" recipe alongside the table-calc alternative.

Deliberately NOT ported: a2td's lossy-apply-detect droppedFilters tracking —
COVERED in tmcp: readback-verify already compares intended-vs-readback
filters at error severity (stricter than a2td's warning) on the worksheet
apply path. Overlap with malformed-set-groupfilter pinned impossible by
test: that rule fires only inside <group> set definitions, this one selects
<filter> nodes with not(ancestor::group).

6/6 new tests green, knowledge suite green, tsc clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(fields): optional session on list-available-fields re-snapshots the live workbook (W-23447478 port)

Port of a2td #209's W-23447478 slice to feature/desktop — the datasource-
recognition P0: an agent listing fields from a stale cache never sees
datasources the user added after the snapshot. With session, the tool now
fetches live workbook XML (same executor path as get-workbook-xml),
rewrites the cache file and stamps its fingerprint sidecar before listing;
without session, cache-only behavior is unchanged. Refresh failure is an
explicit error with a retry-without-session hint — never a silent stale
listing.

Mapped, not copied: tmcp uses 'session' (not a2td's _session) per sibling
tool convention, and readOnlyHint flips to false since the tool now writes
the cache when refreshing (same reasoning as get-workbook-xml). Surface
delta +108 bytes; both surface-budget pins green.

8/8 tool tests, 18 server.desktop + 7 combined-lean pins green, tsc clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(server): host-computed verification receipt on apply responses (W-23447506 port)

Port of a2td #209's promise-check seam to feature/desktop. Every apply-style
success response now ends with one HOST VERIFICATION line the model cannot
fill: worksheet applies derive verified/unverified/failed from preflight
warnings + post-apply readback (the receipt subsumes the old skipped-readback
status sentence — one host-truth line, not two); whole-workbook, dashboard,
and batch applies honestly state their intent is NOT structurally re-verified.

Command layer now returns preflight warnings with the load result
(LoadWorksheetXmlOk.validationWarnings; loadWorkbookXml/loadDashboardXml
Ok payloads) so receipts never re-run validation. Dashboard receipt is a
deliberate extension beyond a2td (message-shaped, no readback — same honest
wording). Key-set-pinned tools (dashboard-auto-apply, bind-template) left
untouched; a2td's lossy-scan receipt clause dropped — tmcp has no
lossy-node detector, and claiming a clean scan that never ran would be the
exact dishonesty this seam exists to prevent.

Receipt suite 6/6; 10 touched-suite files 107 green; server.desktop pins
18 green; tsc + eslint clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(server): classify apply failures into actionable agent guidance (W-23447473 p1 port)

Port of a2td #209's apply-failure classifier to feature/desktop, mapped onto
tmcp's layering: the classifier (pattern table -> failure_class + evidence +
FIX guidance, GENERIC_WRAPPER stripping, undeclared auto-calc detection)
lands beside interpretLoadOutcome in the command layer, and every
load-rejected error message is now formatApplyFailureForAgent output instead
of Desktop's raw wrapper text. McpToolError's three XML-load subclasses pass
a string message through instead of JSON.stringify-wrapping it — which also
un-JSONs the readback-failed fix recipes from W4.

Deliberately not wired (tmcp has no such paths): a2td's datasource-collapse
and connection/ECONN patterns; ExecuteCommandError dispatch failures keep
their existing funnel. 7-case classifier unit suite; command-layer test
proves a real rejected load returns 'Apply failed: ... FIX: ...'.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(routing): edit-in-place for current-sheet asks + fix/repair verbs (W-23447473 p2 port)

Port of a2td #209's edit-in-place routing to feature/desktop. route-spec
gains CURRENT_SHEET_RE ('current/this/that sheet|worksheet' is edit context),
fix/repair join the edit-verb regex, and a narrow NON_SHEET_FIX_RE guard
keeps 'fix the data source' from classifying as a sheet edit (tmcp has no
sheet-edit class to absorb it — deviation from a2td, justified in-code).
DESKTOP_ROUTE_TABLE gains the edit-in-place route so generated instructions
tell skill-less clients to resolve the target and never create a new sheet
unless asked.

Surface truth: the draft's byte math was based on the stale 44,015 comment;
real combined-lean base was 45,677, so the instruction prose is tightened to
fit — measured desktop total 45,336, combined-lean 45,976 (24 bytes under
the 46,000 cliff; next growth must trim, not raise the cap). Route tests pin
verified classifyAskRoute behavior, not guessed shapes.

Full suite 4,300 green (296 files), tsc + eslint clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* style: prettier autofix on 4 ported test files (unblock #520 CI)

The receipt / validationWarnings port left prettier drift on four
desktop test files (trailing comma in Ok({ validationWarnings: [] }),
.spyOn chain wrapping). eslint --fix only; no logic touched. Clears the
7 prettier/prettier errors failing the build (>=22.7.5 <23) and (24.x)
jobs on PR #520.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
…y rewiring (W-23447497) (#521)

* feat(interaction): ask-user clarification tool + fail-closed ambiguity rewiring (W-23447497)

The clarification loop's tmcp half: a2td's tableau-ask-user ported as
'ask-user' (question + blocking|soft urgency + numbered options; pure
formatter, no session — it never touches Desktop; stop-and-wait is in the
tool contract, which even a2td's version lacked). The fail-closed outcomes
that already compute candidates now route them to the human instead of
dead-ending in tool loops: resolve-field ambiguity, bind-template
ambiguous/missing-field guidance, plan-dashboard-creation blocked response,
and DESKTOP_INSTRUCTIONS (edit-in-place 'ask via ask-user' + one blocking
clarification rule). TAS's circuit breaker referenced this exact tool name
before it existed (fixed in tab-agent-south !42) — this makes that recovery
path real.

Surface arithmetic (the real constraint): describe-trims on
validate-proposal / dashboard-auto-apply / dashboard-health-check freed more
than the 1,032-byte tool costs — desktop total 45,336 -> 45,202,
combined-lean 45,976 -> 45,843 (157 headroom); caps ratcheted DOWN. Demo
profile untouched.

Full suite 4,305 green (297 files), tsc + eslint clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refine(desktop): restore ask/title noun-role on dashboard-auto-apply (Andy #521 review)

Andy-lens review flagged the surface trims that funded ask-user cut too
far on the confusable ask/title param pair ('Viz.'/'Sheet.' -> the LLM
can't tell which is the request vs the sheet name). Restore to
'Viz ask'/'Sheet title' and lead the tool description with the
all-or-nothing batch contract (its core semantic). The description reword
is byte-NEGATIVE vs the pinned baseline, funding all but +5 of the param
restoration; that +5 is a deliberate per-tool cap raise (1295->1300) with
inline justification. validate-proposal trims left as-is (no param-swap
risk). combined-lean unaffected; full suite 4305 green, tsc clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
…e 'Sheet 1' (focus-follows harden) (#522)

fix(desktop): canonical-name gate on worksheet/dashboard apply — goto-sheet can never target a stale name (W65 night wave, focus-follows harden)

Parse the <worksheet|dashboard name> from the XML fragment, fail before apply on mismatch (NFC-normalized compare, recovery-oriented FIX message, workbook-wrapped payloads rejected with 'single fragment required'), and thread the canonical name into load, readback, and focusAppliedSheet on both the Agent API and external-API paths.

Root cause twin of the a2td blank-Sheet-1 focus regression seen live 2026-07-16: the helper trusted caller args, so a stale 'Sheet 1' could be applied AND focused.

Validation: 46 targeted tests (4 new red-proofed) + full desktop sweep 2325 passed; tsc + eslint clean.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
port(a2td #214): refine sort_direction inserts computed-sort on unsorted simple bars

Twin of a2td #214 (exec dogfood 'couldn't sort a simple bar chart'). planSortInsertion on the exact safe shape only; shared pseudo-field classifier exclusion (reaches planTopN); template anchor verified against src/desktop/data/templates/magnitude-simple-bar.xml:24. Full desktop sweep 2342 green, surface cliff held (zero growth).

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
…ling focus) (#525)

port(a2td #215): suppress per-sheet focus during plan-driven parallel dashboard builds

Gap CONFIRMED here: plan-dashboard-creation instructs parallel subagent builds (>=5 sheets) and each build-and-apply-worksheet fired goto-sheet — last completer steals focus. Internal planBuildFocus seam (session-scoped marks, zero tool-surface growth); standalone builds keep focusing; final dashboard owns focus. Full desktop sweep 2341 green, surface suites green.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Twin of a2td #213 adapted to tmcp conventions: shared refreshWorkbookCache module (list-available-fields refactored onto it), session-gated refresh-once + retry on not_found, graceful degrade for every failure shape (never a transport tool_error), concurrent dedup per workbookFile. readOnlyHint flipped to false (honest: session refresh writes cache+sidecar). Surface +113B (680 headroom), full desktop sweep 2343 green.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
…d guidance rules) (#527)

knowledge: failure-recovery-honesty module — stale-cache re-read + receipt honesty (on-demand twin of a2td SKILL rules 8/9)

tmcp ships no bundled skill and DESKTOP_INSTRUCTIONS is surface-pinned, so the two agent-guidance rules land as an on-demand knowledge module at tactics/workflow/failure-recovery-honesty.md (auto-discovered by listKnowledgeSlugs; _index.md routed; real-corpus appearance pinned by resources.test.ts). NOT surface-counted — combined-lean cliff test green.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
…ild (#534)

* feat(desktop): route-table gates for authoring skill + plan-before-build

Add two prose entries to DESKTOP_ROUTE_TABLE:
- authoring-skill: load tableau-desktop-authoring before building; on
  repeated unresolved failures load the debugging skill instead of
  brute-forcing manual XML.
- plan-before-build: for multi-viz/dashboard asks, map requirement ->
  encoding -> rule and classify each as MAGNITUDE vs MEMBERSHIP, encoding
  membership with a discrete bucketing dimension, not a raw-measure gradient.

Pin the generated instruction text in tests.

* fix(desktop): recovery line names tableau-agent-debug by exact slug

Recovery path was prose ('the debugging skill'); switch to the exact
registered slug (tableau-agent-debug) so the agent doesn't guess when a
build is already failing. Pin the slug in a test.

* trim tools/list + instructions under the 46k auto-deferral cliff

The two new route entries plus existing tool prose put the combined-lean
measured payload at 46,626 vs the 46,000 cliff. Prose-only trims across
routeTable, bind-template, proposalSchema, plan-dashboard-creation, and
apply-dashboard bring it to ~45.7k with headroom; grandfathered per-tool
caps ratcheted down (2110->1898, 2040->1797, 1601->1557) and the
instructions snapshot re-pinned. Contract lines kept: auto_apply never
applies Call-2 proposals, exact-field-name rule, Call-1->Call-2
provenance, plan-then-build step.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* style: prettier rewrap after byte-trim describe() changes

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Matt Filbert <mattcfilbert@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
…in (#537)

Byte-sync of lockstep classify.ts from a2td claude/w2c-propose-identity
(8181dd5): LlmProposeInput.fields gains optional datasource/column_ref,
emitted only when the summary has >1 datasource or duplicate display
names; single-ds payloads byte-identical. Manifest regenerated via
check-lockstep --update; both repos' manifests now carry identical
hash 87193686 for the classify twin (CLS-003 procedure).

INERT until plumbed on this side (same pattern as the #511 semanticRole
landing): tmcp's propose-instruction/prewarm surfaces don't yet emit the
qualified identities; that plumbing rides the #535 fields-tool
coordination per the port-debt ledger.

Validation firsthand: full suite 4,346 green (298 files), tsc clean,
check-lockstep 6/6.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
…ipt (a2td #216 port) (#538)

Port-audit-verified gap: promise-check.ts mapped readback status
'warning' to outcome 'verified', while readback classifies dropped
computed-sort/shelf-sort-v2 nodes as warning severity — so an apply that
silently lost a promised sort still stamped HOST VERIFICATION verified
(the false-PASS class #216 closed in a2td). Receipt input gains optional
readbackFindings (threaded from LoadWorksheetXmlOk.readbackWarnings,
which already existed — verifier shape unchanged); a promised-sort-loss
warning escalates verified -> failed. All other warnings keep today's
behavior; tmcp's stricter error-severity path untouched.

Cursor-minion port (gpt-5.5-high), orchestrator-adjudicated: full suite
4,351 green firsthand, tsc clean, lockstep 6/6.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
…#217+#222 port, seam-1 packet A) (#539)

* feat(desktop): exact column_ref first-class + shared ref helpers (a2td #217+#222 port, seam-1 packet A)

Port-wave packet A from the content audit. #222 half: exported
COLUMN_REF_REGEX + parseDatasourceQualifiedColumnRef /
parseColumnInstanceRef / parseCanonicalColumnRef /
formatCanonicalColumnRef in field-resolver (same API as a2td), private
regexes in fields.ts / explicit-bind.ts replaced; preserved divergence
(explicit-bind accepts bare [deriv:Base:sfx], fields require qualified);
colon-tolerant instance parsing (fixes [Profit:Ratio] -> [Profit]
mis-parse, caught by minion red run). #217 half: resolveField and
proposal-binding resolveInSummary recognize exact canonical column_ref
FIRST — hit resolves exact, ref-SHAPED miss fails closed (never falls to
fuzzy); datasource selector matches internal name or UNIQUE caption
(ambiguous caption refuses with guidance, Parameters excluded). Gate 5b
closure untouched; listAvailableFields untouched (#535's file).

Version 2.25.4 assumes #538 (2.25.3) merges first — resolve upward on
conflict.

Cursor-minion port (gpt-5.5-high), orchestrator-adjudicated: full suite
4,362 green firsthand, all 3 wrong-bind invariant suites green (210),
tsc clean, lockstep 6/6, eslint clean (4 fixable errors fixed at
adjudication).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* style: prettier fix on port test files

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
…ource selection (a2td #220+#219 port, seam-1 packet B) (#540)

* feat(desktop): exact column_ref first-class + shared ref helpers (a2td #217+#222 port, seam-1 packet A)

Port-wave packet A from the content audit. #222 half: exported
COLUMN_REF_REGEX + parseDatasourceQualifiedColumnRef /
parseColumnInstanceRef / parseCanonicalColumnRef /
formatCanonicalColumnRef in field-resolver (same API as a2td), private
regexes in fields.ts / explicit-bind.ts replaced; preserved divergence
(explicit-bind accepts bare [deriv:Base:sfx], fields require qualified);
colon-tolerant instance parsing (fixes [Profit:Ratio] -> [Profit]
mis-parse, caught by minion red run). #217 half: resolveField and
proposal-binding resolveInSummary recognize exact canonical column_ref
FIRST — hit resolves exact, ref-SHAPED miss fails closed (never falls to
fuzzy); datasource selector matches internal name or UNIQUE caption
(ambiguous caption refuses with guidance, Parameters excluded). Gate 5b
closure untouched; listAvailableFields untouched (#535's file).

Version 2.25.4 assumes #538 (2.25.3) merges first — resolve upward on
conflict.

Cursor-minion port (gpt-5.5-high), orchestrator-adjudicated: full suite
4,362 green firsthand, all 3 wrong-bind invariant suites green (210),
tsc clean, lockstep 6/6, eslint clean (4 fixable errors fixed at
adjudication).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(desktop): planner datasource-qualified fields + apply-side datasource selection (a2td #220+#219 port, seam-1 packet B)

Port-wave packet B, stacked on packet A (#539). #220 half: planner
fields accept exact column_ref OR {query, datasource?}; resolution
cache keyed on (query, selector); task specs carry the resolved common
datasource; ambiguous AND not_found both block the plan (closes the
audit-verified hole where not_found-only still planned with a warning).
#219 half: buildAndApplyWorksheet derives the rewrite datasource from
explicitBind.datasource — the caption/first-workbook-datasource regex
grab is DELETED; no-manifest passthrough infers a single datasource from
the provided refs via the shared parser and FAILS CLOSED on mixed refs
with a prescriptive block; injectTemplate blocks caller DATASOURCE that
disagrees with the resolved mapping datasource. Two characterization
pins deliberately re-baselined (caption-first -> common-ref datasource),
each with a comment naming the new invariant. Zero contact with the
receipt-formatting area (#538's file overlap — verified no conflict).

Cursor-minion port (gpt-5.5-high), orchestrator-adjudicated: full suite
4,368 green firsthand, invariant suites 210 green, tsc clean, lockstep
6/6, eslint clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
…nalSpec verbs (330→333) (#542)

feat(desktop): known-command guard on execute-tableau-command + NotionalSpec verbs in the reference

Ports a2td's command-registry guard (born from the 2026-06-30 fatal-abort
incident): fail-open reference load, crash-prone denylist (show-parameter-
controls pair), unknown-verb refusal with up to 3 did-you-mean suggestions —
wired before executor.executeCommand, server-side only (zero tools/list bytes;
combined-lean ratchet untouched). Also admits the semantic-loop verbs to this
repo's reference (330→333): generate-viz-from-notional-spec,
generate-notional-spec-from-viz, is-analytics-assistant-available, each with a
provenance_note (the generator's user-initiated classification filter drops
pane-invoked commands).

Full suite 4382 passed / 0 failed; tsc clean; prettier clean.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
… + calc-authoring twin (#543)

* docs(knowledge): NotionalSpec semantic loop — live-verified authoring + calc-authoring twin

Port of the a2td notional-spec knowledge with tonight's live corrections:
spec is authoritative replace (ClearSheet:false does not merge), poll-based
readback discipline, write-blind generic route, param-error modal hazard,
WorksheetId 500 avoidance. New companion doc covers the whole-document
round-trip (save/load-underlying-metadata) for calc authoring — the
WW-grade half that had no coverage — with the AA-gated dialog path
explicitly steered away from and numerical-correctness honesty rules.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(routing): plain-viz route goes semantic-first — NotionalSpec before bind-template

The server's own baked instructions were the deepest layer still steering
every client to the XML template path first. The plain-chart route now
tries execute-tableau-command + tabdoc:generate-viz-from-notional-spec
first (sub-second, zero XML, live-proven for 6 chart families tonight),
with bind-template kept as the fallback for out-of-vocabulary families
(waterfall/KPI/funnel) and propose/escalate ambiguity. Route text trimmed
to stay under the 46k tools/list deferral cliff.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
…instrumentation (a2td packet-#0 twin) (#541)

* feat(desktop): episode-lite telemetry — lifecycle tools, tool events, structured promise outcome, route receipt (a2td packet-#0 twin for the Studio binary path)

Env-gated episode JSONL for the eval loop, default OFF (EPISODE_EVENTS=on to
enable; EPISODE_EVENTS_DIR overrides the file-logger directory). Zero product
behavior or response-text change when off; all emission fail-open.

- tableau-begin-episode / tableau-end-episode tools; begin returns
  episode_id in result text so the host transcript (and whole-chat LangSmith
  trace) carries the correlation key.
- tool_start/tool_end/tool_error emitted at the DesktopTool execute choke
  point (duration_ms, success), field names matching the a2td stream.
- classifyWorksheetPromiseOutcome extracted from the receipt formatter (text
  byte-identical); apply sites emit apply_succeeded/readback_verification
  with promise_outcome; whole-doc applies emit unverified.
- route_receipt serialized from sessionRouteState at episode end —
  structural only, deflection prose excluded.
- episode_begin pin tuple: tool_profile, system_prompt_version fallback
  mcp-server@<version>, TABLEAU_DESKTOP_SESSION_ID, env-sourced agent_types
  and langsmith_run_id (Studio-launcher sources tracked as UNKNOWN in the
  eval-on-Studio addendum).
- FileLogger: appendJsonLine extracted so the episode writer reuses the
  per-file mutex machinery; log() semantics unchanged.

Validation (re-run by orchestrator): npx tsc --noEmit clean, npm run lint
clean, vitest 4383 passed | 1 skipped (301 files).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(e2e): tools/list expectations exclude default-off episode-lite tools

The episode lifecycle tools register only when EPISODE_EVENTS=on
(config.episodeEventsEnabled); the e2e expectation was asserting the
full desktopToolNames set and failed on both variants.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
…face (fold-in from experiment) (#544)

* experiment(desktop): TOOL_PROFILE=spec-loop — the ruthless 5-tool spec-loop-first surface

The experiment: is Tableau's native semantic loop enough on its own, with NO XML
tools, NO templates, NO bind-template? SPEC_LOOP_TOOL_PROFILE = execute-tableau-command
+ list-instances/available-fields/worksheets/dashboards. Everything a chart/calc/
dashboard ask needs routes through execute-tableau-command on the /v0 External API
(generate-viz-from-notional-spec for charts; whole-document GET/POST for calcs);
list-* is discovery/readback since the generic /v0 route is write-blind. The #542
known-command guard makes the single command tool safe against hallucinated verbs.

Proven by hand 2026-07-19 on live Desktop: a full analytics workbook (12 authored
calcs, 10 charts, dashboards) in 4.32s, zero XML surgery. This branch is the
product home for that — additive (new profile only), off by default, for Kyler's
review. Full suite 4383 green, combined-lean 46k ratchet intact, tsc clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* style(desktop): eslint --fix import sort on the profile fold-in

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
…dashboard XML (#546)

fix(desktop): carry ancestor xmlns declarations onto extracted sheet/dashboard XML — untouched get→apply round-trips validate clean

Live incident (first Sonnet 5 audition, 2026-07-19): get-worksheet-xml on a
sheet whose top-N groupfilter carries user:-prefixed attributes produced a
fragment missing the workbook root's xmlns:user declaration; the unmodified
apply-worksheet preflight then failed well-formed-xml (NamespaceError:
prefix is non-null and namespace is null) and the model hand-repaired the
namespace to proceed. Extraction now copies xmlns/xmlns:* from the workbook
root onto the extracted element (shared carryNamespaceDeclarations helper);
dashboard extraction had the identical bug shape and uses the same helper.
Validator strictness unchanged. Failing-first regression tests reproduce the
live error verbatim; metadata tree 101 green; tsc clean.
Authored by a Sonnet 5 builder subagent; adjudicated, re-verified, and
committed by the session orchestrator.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
…crete sort/top-N verification anchors (#547)

* docs(knowledge): wrong-fork redirects from XML-era calc docs to the semantic calc twin + explicit sort/top-N readback blind spot

Live audition receipts (first Sonnet 5 through TAS, 2026-07-19 morning): on a
running-total ask the model consulted calc-fields/table-calcs and descended
into workbook-XML surgery — the XML-era docs out-rank the semantic twin in
knowledge search and carried no live-Desktop redirect. Also lands the blind-
spot line the first Sonnet audition itself filed: nothing in-loop verifies a
sort/top-N landed; state intent, not data outcomes, unless read back.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(knowledge): NotionalSpec failure-recovery module — the crash cart for the semantic loop

Ghost gen-9 Move 2, filled with the singing session's live forensics:
error-class calibration (404 name / 400 envelope / 500 spec-contract),
unknown-error-on-writes triage (modal vs dead app), full-spec-restore as
the undo, re-inventory-after-interruption (the stale whole-document
clobber), refine-readback race, and the say-the-mechanism-change rule.
Cross-linked from notional-spec-authoring.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
mattcfilbert and others added 27 commits July 19, 2026 05:37
…-first, not straight to XML (#548)

fix(desktop): search-commands empty-result recommendation steers semantic-first, not straight to XML

The reference's recommendation_when_no_invocable_match (and the code
fallback) told every no-match search 'Use workbook JSON editing instead' —
observed live steering the first Sonnet 5 audition toward XML surgery while
it hunted for sheet commands mid-chart-build. The recommendation now names
the NotionalSpec loop for viz asks and the workbook-document round-trip for
calcs, with XML editing demoted to last resort — matching the #543/#544
semantic-first routing everywhere else in the server.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
… (fabricated specs die at the tool, not as product 500s) (#545)

feat(desktop): pre-dispatch guard for generate-viz-from-notional-spec — fabricated specs die at the tool, not as product 500s

Live incident (first Sonnet 5 audition through tab-agent-south, 2026-07-19):
the model invoked the command with an invented schema (mark/columns/rows/
title + a worksheetName param); the product returned a raw 500 and the fast
path was lost to fallback. This guard validates the v0.2 contract from the
notional-spec-authoring knowledge module before dispatch and answers with
FIX lines carrying the minimal valid example and the expertise URI. Twin of
a2td #232's param-contract guard, extended to the spec payload.

13 new tests; desktop tree 2423 green; tsc + eslint clean.
Authored by a Sonnet 5 builder subagent; adjudicated, re-verified, and
committed by the session orchestrator.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
…-apply race) (#549)

fix(desktop): refine-worksheet polls its confirmation readback — no more false refined:false on the async-apply race

Confirmed live (2026-07-19, minimal repro in audition receipts): top_n
refine applied, the single post-apply readback raced the async settle and
reported refined:false ('readback did not contain the expected Top-N
filter'); the immediate retry then refused with 'a Top-N filter already
exists'. The readback now polls up to 8×250ms (the knowledge doc's
documented working interval), confirming on first match; exhaustion keeps
the honest refusal and names the async-settle possibility. Fetch and apply
remain exactly-once.

Race-simulation tests (fake timers): settle on poll 3, boundary poll 8,
never-lands still refuses. 228 tests green across the touched tree; tsc
clean. Sibling single-read race flagged, NOT fixed here (own ticket):
verifyPostApplyWorksheetReadback (loadWorksheetXml.ts:83) — the host-
verification seam behind every worksheet apply.

Authored by a Sonnet 5 builder subagent; adjudicated, re-verified, and
committed by the session orchestrator.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
… overrides (modal-killer) (#550)

feat(desktop): param-contract guard for every command — wrong param shapes die at the tool, not as modals on the user's screen

Port of a2td #232, generalized: execute-tableau-command now validates args
against the bundled reference's per-command parameters[] (direction:'in',
required flags — all 767 entries carry explicit booleans) after
validateKnownCommand and before the NotionalSpec payload guard. Unknown
keys and missing required params are rejected with FIX lines; commands
flagged opens_blocking_dialog get the stricter modal warning. Fails open
when the reference is unreadable or a command declares zero in-params.

PLUS the day's sharpest finding, encoded as LIVE_PARAM_OVERRIDES: the
reference is WRONG at /v0 runtime for tabdoc:goto-sheet — it declares
WindowLocator (required:true), but live {'WindowLocator': name} = 500 + the
blocking modal 47BF7751 (reproduced twice on a human's screen today), while
{'Sheet': name} — absent from the reference — SUCCEEDED three times. The
override table is where live-verified corrections accumulate until the
reference generator learns the /v0 dialect.

Flagged, not fixed (own ticket): focusAppliedSheet.ts calls goto-sheet
internally with {sheet: name} via the executor directly — bypasses this
guard and matches NO known contract; second potential modal source.

15 guard tests + 6 integration tests; desktop tree 179 files / 2458 green;
tsc + eslint clean. Guard core authored by a Sonnet 5 builder subagent
(which correctly flagged the reference-vs-runtime contradiction instead of
resolving it silently); live probes, override design, #545 chain-wiring,
and test corrections by the session orchestrator.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
…BF7751 killer) (#551)

* fix(desktop): verify the applied sheet exists before goto-sheet — focusing an unknown name throws blocking modal 47BF7751

goto-sheet at a name Desktop doesn't know throws a BLOCKING modal ('bad
value: sheet') instead of returning an error, and an apply is async enough
that its sheet can be missing at focus time — live-reproduced three times
tonight on the eval stage (probe matrix: lowercase sheet param + valid value
SUCCEEDED; capital Sheet + valid SUCCEEDED; any param + bogus value = the
modal). focusAppliedSheetBestEffort now polls list-worksheets/list-dashboards
(4 x 250ms) and skips focus with a warning log if the sheet never appears: a
skipped focus is a log line, a modal is a wedged stage.

Test mocks move from strict Nth-call sequences to dispatching executors that
answer the existence poll with the canonical names from the applied XML, so
the NFC/NFD canonicalization assertions still hold; new modal-killer tests
pin the skip path for both worksheets and dashboards.

* style: prettier on the modal-killer test mocks
…tainment guard (#552)

* feat(desktop): surface the document round-trip in the command reference (discoverability)

Live root cause (2026-07-19, two banked receipts): on calc-heavy asks the agent
searched 'underlying-metadata' -> 0 hits -> concluded 'not available in this
build' -> fell back to hand-writing workbook XML and drowned. The verbs exist
(executor maps them to GET/POST /v0/workbook/document) but had zero entries in
the JSON that search-commands, validateKnownCommand, and paramContractGuard all
read — undiscoverable AND uninvocable through the tool.

Adds honest entries for tabui:save-underlying-metadata / load-underlying-metadata
(load declares required in-param 'text'), allowlists both, and names the verbs in
the zero-hit recommendation. Empirically gated: her exact failing query now
returns the pair at ranks 1-2; 'calculated field' puts save at rank 1; guard
rejects load{} and load{filepath} with actionable steers.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(desktop): containment guard for load-underlying-metadata (whole-document or nothing)

Adversarial-review P1 mitigation for surfacing the round-trip verbs: a
pre-dispatch guard on execute-tableau-command rejects fragment loads (non-
<workbook> root, no datasources/worksheets), loads that would DROP live
worksheets (steer: delete-worksheet), and gross-shrink documents (<50% of
live) — with fail-open on live-read infrastructure failure. Guard built by
GPT-5.5 minion, adjudicated + full suite re-run firsthand (4453 green).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(desktop): readback-verify law — a completed load envelope does not prove application

Live-proven 2026-07-19 (Desktop main.26.0715, three probes): document loads
that ADD columns or rewrite worksheet content apply; loads that REMOVE a
datasource column are silently ignored — completed envelope, no effect.
Teach: after every load, re-read with save-underlying-metadata and confirm
the change landed. Applied to the load entry's description and the
notional-spec-calc-authoring knowledge module.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(desktop): guard false-positive — real documents carry comments before the workbook root

FATAL caught by live audition probe: Desktop documents open with a build
comment between the XML prolog and <workbook> ('<!-- build main.26.0715 -->');
the root regex forgot comments, so the guard rejected EVERY legitimate
whole-document load as a fragment — blocking the exact path it exists to
protect. Live receipt: verse 2's 'fragment' rejections were false positives.
Regression fixture uses the verbatim real head. Lesson (now in the stack
skill): probe the guard's ACCEPT path against a real artifact, not only its
reject paths against synthetic fixtures.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(desktop): author-calc tool — semantic calc authoring, no document in the agent's hands

Live evidence (two drown receipts, 2026-07-19): agents on the manual round-trip
fragment the 50KB document re-emission. author-calc takes caption+formula
primitives and does the round-trip internally: read → unique-name splice into
the target datasource (single non-Parameters default, candidates listed on
ambiguity) → guard-validate → load → READBACK-VERIFY (completed does not prove
applied). Caption collisions rejected pre-write. Built by GPT-5.5 minion,
adjudicated firsthand.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore(desktop): fund author-calc under the 46k surface cliff — describe trims, ratchets tightened

46,691 → 45,753: prose-only trims across the six fattest describes; per-tool
ratchets lowered to the new sizes. Load-bearing semantics restored after
adjudication where the trim cut meaning (Call-2 proposal provenance on
bind-template, plan-dashboard-creation pointer and applies-to-LIVE warning on
build-and-apply-worksheet). Full suite 4,460 green, tsc clean, combined-lean
under cap with headroom.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(desktop): calc knowledge — author-calc first; calc-editor door is human-in-loop only

open-calc-editor-with-custom-calc returns completed WITHOUT committing (opens
the editor pre-filled; open editor blocks the UI thread — live-proven twice
2026-07-19). Knowledge now routes: author-calc tool first, manual round-trip
as fallback, calc-editor commands never in unattended runs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(desktop): author-calc — only the top-level datasources block holds real datasources

Live verse receipt (2026-07-19): worksheet views clone <datasource> elements,
so a Superstore workbook offered 'Sample - Superstore, Sample - Superstore' as
candidates, and splicing into the clone was silently discarded by Tableau (the
readback-verify caught it). Selection now scans only the first <datasources>
block.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(desktop): author-calc — always splice at datasource end; relation columns are a position trap

Live untaught-verse receipt (4 failed author-calc calls, readback-verify
caught every one): <connection>/<relation>/<columns> holds schema <column
ordinal=.../> nodes; insert-after-last-column landed the calc inside the
relation block and Tableau silently discarded it. End-of-datasource is the
position every successful live splice used tonight.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(desktop): real-document replay fixture for author-calc + active-sheet law in spec knowledge

Every author-calc bug tonight was invisible to synthetic fixtures and cost a
live verse to find (datasource clones; relation-column position trap). The
replay test runs the tool against a real saved Desktop document (username
scrubbed) offline. Knowledge: generate-viz renders on the ACTIVE sheet —
goto-sheet immediately before, or it clobbers a neighbor (live receipt: the
agent detected the clobber via readback, restored the sheet, and rerouted —
correct behavior, avoidable trap).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(desktop): author-calc resolves sibling-calc caption references to internal names

Live finale receipt (2026-07-19): all six author-calc calls succeeded, but 5
of 6 layered formulas referenced sibling calcs by CAPTION ([Member Profit],
[Profit Tier]) — Tableau formulas resolve internal names, and authored calcs
get [Calculation_N] names the agent cannot know, so every dependent calc
flagged invalid and the chart leg died. The tool now rewrites bracketed
caption tokens to internal names at splice time (base fields where caption
equals name pass through untouched).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(desktop): author-set — param-linked Top/Bottom-N sets via document round-trip

Wraps the proven set shape (Segno's golden groupfilter + CODA's live param-link
proof) as an author-* verb: primitives in (caption/dimension/orderBy/count/end),
golden <group><groupfilter> XML server-side, spliced at datasource-end, readback-
verified. count accepts a literal N or a [Parameters].[X] token — the melody plays
over the key signature. Offline replay (5/5) incl. real 49KB Superstore document.

Live-mapped this session (receipts): the document round-trip is the whole dialect —
calcs/sets MERGE; parameters are OPEN-only (frozen to merge, all headless create/edit
commands hit dlg.DoModal). Set authoring needs no parameter mutation: it references
the param by token and Tableau resolves it at runtime.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(desktop): author-action — parameter-change actions via document round-trip

Live-proven this session (CODA): a workbook-level <actions> block MERGES via the
round-trip — spliced <edit-parameter-action> targeting [Parameters].[Parameter 1]
survived readback, link intact. This refuted the map-agent's OPEN-only assumption
(the same trap sets fell into) — a live probe beat the reasoning.

Wraps it as a verb: primitives in (caption/sourceWorksheet/sourceField/
targetParameter/activation), golden <edit-parameter-action> XML server-side,
creates the workbook <actions> block if absent (between </datasources> and
<worksheets>) or appends with a fresh [ActionN] name, readback-verified. Offline
replay 4/4. The interactivity layer over the key signature.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(desktop): format-labels verb + surface-fund the author-* trio under the 46k cliff

format-labels: turns mark labels ON/OFF for a worksheet via the document round-trip
(PROVEN live 2026-07-19 — <format attr='mark-labels-show'> in a pane MERGES and
survives readback). Idempotent: rewrites an existing rule or inserts one; readback-
verified. Offline replay 4/4.

Funding: author-set/author-action/format-labels pushed the combined tool surface
past the 46k tools/list auto-deferral cliff (and the combined-lean profile's). Per
the ratchet doctrine (never raise the cap — trim to fit), emptied redundant single-
token .describe() stubs across the fattest tools (plan-dashboard-creation, build-and-
apply-dashboard, validate-proposal, dashboard-health-check, inject-template, dashboard
-auto-apply, add-field, apply-worksheet, resolve-field, build-and-apply-worksheet).
Field names + enums already carry the meaning; empty describe drops the JSON key
entirely (verified). Lowered the grandfather caps to lock the wins in; removed the
tools that dropped under the 1200B per-tool budget. Full suite 4476 green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(desktop): author-parameter — the honest modulation verb (seed at OPEN, reopen explicit)

Parameters are the ONE shape frozen to live merge (PROVEN live 2026-07-19, CODA:
create/add/value-edit all silently refused; every headless create/edit command is a
blocking dlg.DoModal). A parameter is born ONLY at OPEN. So this verb does the
document surgery — splices the <column param-domain-type> into the Parameters ds
(creating that ds if absent; an empty one is dropped on load, one with a real column
survives) — writes a reopen-ready stage, and returns { stagePath, reopenRequired:
true }. The reopen + re-pin is the serving layer's job, surfaced explicitly, NEVER
hidden inside the tool (ghost gen-18: it's a session-lifecycle event). The reopen
preserving merged calcs/sets/actions/formatting is live-proven. Parameters = the key
signature; everything else is the melody merged over it. Offline replay 4/4.

Funds the 5th verb under both 46k cliffs by emptying redundant describe stubs
(get-worksheet-xml, apply-dashboard, remove-field). Full suite 4480 green, tsc clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* docs(knowledge): dynamic-authoring router — teach the 5 author-* verbs + key-signature/melody law

New tactics/data/notional-spec-dynamic-authoring.md is the routing map that steers the
agent to author-calc/author-set/author-parameter/author-action/format-labels instead
of hand-XML, with the one governing law (params=key signature born at OPEN; calcs/sets/
actions/formatting=melody merged live) and the full WW44-shape recipe in build order.
Cross-linked bidirectionally with the calc-authoring doc. Every claim live-proven this
session. This is the teach-Sonnet layer — the verbs are the capability, this is discovery.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* docs(knowledge): dialog-command misclassification guard — 15 commands the reference marks safe but block

The command reference marks 15 *DialogCommand-sourced commands as opens_blocking_dialog=
false + agent_can_invoke=true; invoking them unattended pops a modal that wedges the
session (live-proven: edit-existing-parameter hung 12s while health stayed OK). Names the
15, gives the trust rule (source name > derived flag), routes to author-* verbs instead,
and records the generator-fix heuristic (source =~ /Dialog/ => force blocking). Refines the
ghost's 'reference omission' into the real bug: misclassification, not omission.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(desktop): dynamic-authoring TOOL_PROFILE — the 10-tool singable no-XML surface

Answers 'make it shorter': instead of trimming describe stubs to squeeze verbs under
the 46k tools/list cliff, define a lean profile that IS the whole authoring language.
TOOL_PROFILE=dynamic-authoring = spec-loop's 5 (execute-tableau-command for the
NotionalSpec chart/dashboard spine + discovery/readback) + the 5 author-* verbs
(calc/set/parameter/action/format-labels). Ten semantically-named tools cover the full
WW44 dynamic dialect; zero agent-visible XML/cache/validation/template tools.

Measured wire surface: 9,644 bytes vs the 46,000 cliff — 79% headroom (vs the full
45-tool surface that fights the cliff). The cliff stops being a fight structurally.
Full set stays reachable via TOOL_PROFILE=full. Tests: registers exactly the 10, banishes
XML/template tools, asserts <30k. Full suite 4482 green, tsc clean.

Ghost gen-19 Move 1 (the ruthless surface already half-existed as spec-loop; this bolts
the write half on and names it).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(desktop): dynamic-authoring grows ask-user + search-commands — the 12-tool singable surface

CODA's joy-cut overshot: a singer who cannot ask for help or discover the
execute-tableau-command dialect is not lean, she is mute. Re-measured at
11,500 wire bytes — 75% headroom under the 46k cliff.

* feat(desktop): author-parameter completes the reopen in-call — the key signature no longer stops the song

CODA's one seam, closed tool-side: getExecutor discovers manifests dynamically
and getDesktopConfig() re-reads env per call, so the tool itself relaunches the
seeded stage (open -n, live-probed: manifest 2s, API-ready 3s), readback-verifies
the parameter in the reopened doc, re-pins TABLEAU_DESKTOP_SESSION_ID in-process
(only when a pin already existed), then SIGTERMs the old instance (probed: dies
clean, no recovery sibling). Any failure degrades to the honest reopenRequired
stage-on-disk result. stagePath is now optional — untaught agents never invent
filesystem paths; the default stages beside the live workbook's own file.

* chore: sync package-lock version to 2.26.0

* fix(desktop): reopen launches the bundle binary directly — open -a loses the document among instances

Dress rehearsal caught two live defects the offline suite could not: (1) with
multiple instances of the bundle running, 'open -n -a' starts a fresh instance
but LaunchServices routes the document Apple Event unpredictably — the reopened
instance came up EMPTY; spawning <bundle>/Contents/MacOS/<binary> <stage>
detached carries the document deterministically (and puts it in argv). (2) The
default stagePath cannot derive from the old process's argv for the same reason —
it now stages under ~/Documents/My Tableau Repository/Workbooks (real user path;
Desktop has crashed saving from sandboxed tmp dirs). E2E-proven live: reopened
78197→78328, param verified in the new instance's readback.

* docs(knowledge): author-parameter reopens in-call — the router doc catches up to the shipped verb

* feat(desktop): dynamic-authoring route joins the instructions — the verbs are named where the singer reads first

Finale autopsy: untaught Sonnet landed both parameters through the in-call
reopen but burned 55 of 65 calls spelunking raw commands for the set and
dashboard, and never reached author-set — the tool list carried the verbs but
the initialize instructions never routed a dynamic ask to them. Segno's
discoverability law, one level up: ink the sign in the score she reads at
note one.

* fix(desktop): trim the dynamic-authoring route under the combined-lean 46k cliff

The prior commit pushed combined-lean to 46,116 — the full suite caught it
after an ungated commit chain (the lesson is the gate order, re-learned).

* feat(desktop): dialog blocklist enforced in the param guard — the boo modal dies here

revert-workbook-ui fired Error 47BF7751 on Matt's screen three times in one
verse: the reference says safe, CODA's probe said modal, the knowledge doc knew,
but nothing ENFORCED it. Now the guard refuses all 16 live-proven dialog
commands outright, each with a FIX redirecting to the sanctioned alternative
(author-parameter, author-action, NotionalSpec keys, or author-forward).
Every annoyance dies exactly once.

* fix(desktop): author-calc resolves PARAMETER captions to qualified [Parameters].[N] refs — the empty-sheet killer

Verse-3 live receipt: the singer's filter calc named two parameters by caption;
resolution only covered the target datasource's columns, so the refs never bound
and the sheet rendered empty on Matt's screen. Parameter captions now resolve
from the Parameters datasource to the qualified cross-datasource form, set after
the field map so a caption collision resolves to the parameter — which is what a
dynamic-ask formula means.

* feat(desktop): notionalSpecGuard validates full v0.2 vocabulary — kills the silent-empty-sheet class

Live eval receipt (2026-07-20 TUTTI Blake-set wave): a spec with
aggregation:'none' (not v0.2) and a rangeFilter using max: (v0.2 is
start/end) passed the guard, codegen generated NOTHING, and the /v0
success envelope shipped an empty sheet as a PASS. The guard now
validates per-field keys/enums (data/type/role/aggregation/encoding,
'shelf' redirect, detail/tooltip v0.3 gate), sort, rangeFilters,
relativeDateFilters (plural periods, next/previous), and
categoricalFilters — every rejection carries a FIX line that teaches
the correct vocabulary in-context. Accept fixtures are the six
live-proven TUTTI golden specs; reject fixtures are the live failing
payloads.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(desktop): teach the NotionalSpec dialect in the census entries for the semantic-loop pair

The dynamic-authoring profile has no knowledge tools, so search-commands
is the singer's only reachable teacher — expertise:// pointers are dead
ends there. The generate-viz entry now carries the v0.2 cheat-sheet
(chart enum, field props, filter/sort shapes), the author-calc-then-
reference-by-caption idiom (aggregation 'default', never 'none'), the
string-date DATE() idiom, and the write-blind law (probed raw
2026-07-20: /v0 invokeCommand returns state:SUCCEEDED with no result,
no operations route — verify by readback, always). The read-spec twin's
entry now states its output is NOT returned by the /v0 API (product
gap) and routes readers to document readback. Both entries get real
parameter metadata (was []).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* style: autofix lint (prettier, import-sort) + explicit return type on test helper

Green firsthand: lint clean, build ok, 4531 tests pass. CI was red on lint only.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test(desktop): mock spawnDetached in the 3 reopenFromStage tests that omitted it

CI Tests step failed (both node matrices): the timeout + 2 success-path reopen
tests fell through to defaultSpawnDetached, which spawned the real macOS Tableau
binary — ENOENT on the Linux runner fired as an async unhandled rejection after
the test passed, exiting the process 1. Passed locally only because the app path
exists on macOS. All other reopen tests already mock it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(desktop): the singer sings native by default — unset TOOL_PROFILE now selects the 12-tool dynamic-authoring surface

Flips the desktop default from the full 40-tool surface (raw XML get/apply +
templates + cache) to the lean native-authoring surface: semantic loop via
execute-tableau-command + the five author-* verbs + discovery/readback, zero
agent-visible XML. 'full' is the explicit opt-in escape hatch; demo/spec-loop/
combined-lean unchanged. Consumers that need the XML surface set TOOL_PROFILE=full.
TAS + the eval kit already set the profile explicitly, so the singer is unaffected;
this makes native the DEFAULT for every unset consumer (ghost gen-19 standing move).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test(e2e): desktop + combined list-tools expect the lean native default surface

The TOOL_PROFILE default flip (unset -> dynamic-authoring) changes what both the
standalone desktop server and the combined bundle's desktop half register when no
profile is set. Assert the lean 12-tool native surface (raw XML tools opt-in via
TOOL_PROFILE=full). Desktop list-tools verified locally; combined needs CI web creds.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(desktop): data-loss guard matches double-quoted worksheet names (was failing open)

Review P1: WORKSHEET_NAME_RE only matched name='...' (single quotes), so a live
worksheet with a double-quoted name was invisible to the dropped-sheet check and
could be silently dropped on reload — the exact data-loss this guard exists to
prevent, failing OPEN. Match both quote styles (the pattern formatLabels.ts already
uses for the same attribute). Regression test with a double-quoted live sheet.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test(e2e): skip flaky live Admin Insights TS-Events query (CI timeout)

The live query-admin-insights-ts-events E2E times out at 30s in CI while
sibling admin-insights queries return in <1s; it passes locally. This is
server-side latency variance on the heaviest datasource, unrelated to the
document-round-trip changes on this branch. Skipped with a re-enable note;
tracked in the Studio dogfood backlog.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
… slim surface, Call-2 auto-apply, result-carried readback (#554)

* feat(desktop): surface the document round-trip in the command reference (discoverability)

Live root cause (2026-07-19, two banked receipts): on calc-heavy asks the agent
searched 'underlying-metadata' -> 0 hits -> concluded 'not available in this
build' -> fell back to hand-writing workbook XML and drowned. The verbs exist
(executor maps them to GET/POST /v0/workbook/document) but had zero entries in
the JSON that search-commands, validateKnownCommand, and paramContractGuard all
read — undiscoverable AND uninvocable through the tool.

Adds honest entries for tabui:save-underlying-metadata / load-underlying-metadata
(load declares required in-param 'text'), allowlists both, and names the verbs in
the zero-hit recommendation. Empirically gated: her exact failing query now
returns the pair at ranks 1-2; 'calculated field' puts save at rank 1; guard
rejects load{} and load{filepath} with actionable steers.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(desktop): containment guard for load-underlying-metadata (whole-document or nothing)

Adversarial-review P1 mitigation for surfacing the round-trip verbs: a
pre-dispatch guard on execute-tableau-command rejects fragment loads (non-
<workbook> root, no datasources/worksheets), loads that would DROP live
worksheets (steer: delete-worksheet), and gross-shrink documents (<50% of
live) — with fail-open on live-read infrastructure failure. Guard built by
GPT-5.5 minion, adjudicated + full suite re-run firsthand (4453 green).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(desktop): readback-verify law — a completed load envelope does not prove application

Live-proven 2026-07-19 (Desktop main.26.0715, three probes): document loads
that ADD columns or rewrite worksheet content apply; loads that REMOVE a
datasource column are silently ignored — completed envelope, no effect.
Teach: after every load, re-read with save-underlying-metadata and confirm
the change landed. Applied to the load entry's description and the
notional-spec-calc-authoring knowledge module.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(desktop): guard false-positive — real documents carry comments before the workbook root

FATAL caught by live audition probe: Desktop documents open with a build
comment between the XML prolog and <workbook> ('<!-- build main.26.0715 -->');
the root regex forgot comments, so the guard rejected EVERY legitimate
whole-document load as a fragment — blocking the exact path it exists to
protect. Live receipt: verse 2's 'fragment' rejections were false positives.
Regression fixture uses the verbatim real head. Lesson (now in the stack
skill): probe the guard's ACCEPT path against a real artifact, not only its
reject paths against synthetic fixtures.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(desktop): author-calc tool — semantic calc authoring, no document in the agent's hands

Live evidence (two drown receipts, 2026-07-19): agents on the manual round-trip
fragment the 50KB document re-emission. author-calc takes caption+formula
primitives and does the round-trip internally: read → unique-name splice into
the target datasource (single non-Parameters default, candidates listed on
ambiguity) → guard-validate → load → READBACK-VERIFY (completed does not prove
applied). Caption collisions rejected pre-write. Built by GPT-5.5 minion,
adjudicated firsthand.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore(desktop): fund author-calc under the 46k surface cliff — describe trims, ratchets tightened

46,691 → 45,753: prose-only trims across the six fattest describes; per-tool
ratchets lowered to the new sizes. Load-bearing semantics restored after
adjudication where the trim cut meaning (Call-2 proposal provenance on
bind-template, plan-dashboard-creation pointer and applies-to-LIVE warning on
build-and-apply-worksheet). Full suite 4,460 green, tsc clean, combined-lean
under cap with headroom.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(desktop): calc knowledge — author-calc first; calc-editor door is human-in-loop only

open-calc-editor-with-custom-calc returns completed WITHOUT committing (opens
the editor pre-filled; open editor blocks the UI thread — live-proven twice
2026-07-19). Knowledge now routes: author-calc tool first, manual round-trip
as fallback, calc-editor commands never in unattended runs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(desktop): author-calc — only the top-level datasources block holds real datasources

Live verse receipt (2026-07-19): worksheet views clone <datasource> elements,
so a Superstore workbook offered 'Sample - Superstore, Sample - Superstore' as
candidates, and splicing into the clone was silently discarded by Tableau (the
readback-verify caught it). Selection now scans only the first <datasources>
block.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(desktop): author-calc — always splice at datasource end; relation columns are a position trap

Live untaught-verse receipt (4 failed author-calc calls, readback-verify
caught every one): <connection>/<relation>/<columns> holds schema <column
ordinal=.../> nodes; insert-after-last-column landed the calc inside the
relation block and Tableau silently discarded it. End-of-datasource is the
position every successful live splice used tonight.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(desktop): real-document replay fixture for author-calc + active-sheet law in spec knowledge

Every author-calc bug tonight was invisible to synthetic fixtures and cost a
live verse to find (datasource clones; relation-column position trap). The
replay test runs the tool against a real saved Desktop document (username
scrubbed) offline. Knowledge: generate-viz renders on the ACTIVE sheet —
goto-sheet immediately before, or it clobbers a neighbor (live receipt: the
agent detected the clobber via readback, restored the sheet, and rerouted —
correct behavior, avoidable trap).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(desktop): author-calc resolves sibling-calc caption references to internal names

Live finale receipt (2026-07-19): all six author-calc calls succeeded, but 5
of 6 layered formulas referenced sibling calcs by CAPTION ([Member Profit],
[Profit Tier]) — Tableau formulas resolve internal names, and authored calcs
get [Calculation_N] names the agent cannot know, so every dependent calc
flagged invalid and the chart leg died. The tool now rewrites bracketed
caption tokens to internal names at splice time (base fields where caption
equals name pass through untouched).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(desktop): author-set — param-linked Top/Bottom-N sets via document round-trip

Wraps the proven set shape (Segno's golden groupfilter + CODA's live param-link
proof) as an author-* verb: primitives in (caption/dimension/orderBy/count/end),
golden <group><groupfilter> XML server-side, spliced at datasource-end, readback-
verified. count accepts a literal N or a [Parameters].[X] token — the melody plays
over the key signature. Offline replay (5/5) incl. real 49KB Superstore document.

Live-mapped this session (receipts): the document round-trip is the whole dialect —
calcs/sets MERGE; parameters are OPEN-only (frozen to merge, all headless create/edit
commands hit dlg.DoModal). Set authoring needs no parameter mutation: it references
the param by token and Tableau resolves it at runtime.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(desktop): author-action — parameter-change actions via document round-trip

Live-proven this session (CODA): a workbook-level <actions> block MERGES via the
round-trip — spliced <edit-parameter-action> targeting [Parameters].[Parameter 1]
survived readback, link intact. This refuted the map-agent's OPEN-only assumption
(the same trap sets fell into) — a live probe beat the reasoning.

Wraps it as a verb: primitives in (caption/sourceWorksheet/sourceField/
targetParameter/activation), golden <edit-parameter-action> XML server-side,
creates the workbook <actions> block if absent (between </datasources> and
<worksheets>) or appends with a fresh [ActionN] name, readback-verified. Offline
replay 4/4. The interactivity layer over the key signature.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(desktop): format-labels verb + surface-fund the author-* trio under the 46k cliff

format-labels: turns mark labels ON/OFF for a worksheet via the document round-trip
(PROVEN live 2026-07-19 — <format attr='mark-labels-show'> in a pane MERGES and
survives readback). Idempotent: rewrites an existing rule or inserts one; readback-
verified. Offline replay 4/4.

Funding: author-set/author-action/format-labels pushed the combined tool surface
past the 46k tools/list auto-deferral cliff (and the combined-lean profile's). Per
the ratchet doctrine (never raise the cap — trim to fit), emptied redundant single-
token .describe() stubs across the fattest tools (plan-dashboard-creation, build-and-
apply-dashboard, validate-proposal, dashboard-health-check, inject-template, dashboard
-auto-apply, add-field, apply-worksheet, resolve-field, build-and-apply-worksheet).
Field names + enums already carry the meaning; empty describe drops the JSON key
entirely (verified). Lowered the grandfather caps to lock the wins in; removed the
tools that dropped under the 1200B per-tool budget. Full suite 4476 green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(desktop): author-parameter — the honest modulation verb (seed at OPEN, reopen explicit)

Parameters are the ONE shape frozen to live merge (PROVEN live 2026-07-19, CODA:
create/add/value-edit all silently refused; every headless create/edit command is a
blocking dlg.DoModal). A parameter is born ONLY at OPEN. So this verb does the
document surgery — splices the <column param-domain-type> into the Parameters ds
(creating that ds if absent; an empty one is dropped on load, one with a real column
survives) — writes a reopen-ready stage, and returns { stagePath, reopenRequired:
true }. The reopen + re-pin is the serving layer's job, surfaced explicitly, NEVER
hidden inside the tool (ghost gen-18: it's a session-lifecycle event). The reopen
preserving merged calcs/sets/actions/formatting is live-proven. Parameters = the key
signature; everything else is the melody merged over it. Offline replay 4/4.

Funds the 5th verb under both 46k cliffs by emptying redundant describe stubs
(get-worksheet-xml, apply-dashboard, remove-field). Full suite 4480 green, tsc clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* docs(knowledge): dynamic-authoring router — teach the 5 author-* verbs + key-signature/melody law

New tactics/data/notional-spec-dynamic-authoring.md is the routing map that steers the
agent to author-calc/author-set/author-parameter/author-action/format-labels instead
of hand-XML, with the one governing law (params=key signature born at OPEN; calcs/sets/
actions/formatting=melody merged live) and the full WW44-shape recipe in build order.
Cross-linked bidirectionally with the calc-authoring doc. Every claim live-proven this
session. This is the teach-Sonnet layer — the verbs are the capability, this is discovery.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* docs(knowledge): dialog-command misclassification guard — 15 commands the reference marks safe but block

The command reference marks 15 *DialogCommand-sourced commands as opens_blocking_dialog=
false + agent_can_invoke=true; invoking them unattended pops a modal that wedges the
session (live-proven: edit-existing-parameter hung 12s while health stayed OK). Names the
15, gives the trust rule (source name > derived flag), routes to author-* verbs instead,
and records the generator-fix heuristic (source =~ /Dialog/ => force blocking). Refines the
ghost's 'reference omission' into the real bug: misclassification, not omission.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(desktop): dynamic-authoring TOOL_PROFILE — the 10-tool singable no-XML surface

Answers 'make it shorter': instead of trimming describe stubs to squeeze verbs under
the 46k tools/list cliff, define a lean profile that IS the whole authoring language.
TOOL_PROFILE=dynamic-authoring = spec-loop's 5 (execute-tableau-command for the
NotionalSpec chart/dashboard spine + discovery/readback) + the 5 author-* verbs
(calc/set/parameter/action/format-labels). Ten semantically-named tools cover the full
WW44 dynamic dialect; zero agent-visible XML/cache/validation/template tools.

Measured wire surface: 9,644 bytes vs the 46,000 cliff — 79% headroom (vs the full
45-tool surface that fights the cliff). The cliff stops being a fight structurally.
Full set stays reachable via TOOL_PROFILE=full. Tests: registers exactly the 10, banishes
XML/template tools, asserts <30k. Full suite 4482 green, tsc clean.

Ghost gen-19 Move 1 (the ruthless surface already half-existed as spec-loop; this bolts
the write half on and names it).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(desktop): dynamic-authoring grows ask-user + search-commands — the 12-tool singable surface

CODA's joy-cut overshot: a singer who cannot ask for help or discover the
execute-tableau-command dialect is not lean, she is mute. Re-measured at
11,500 wire bytes — 75% headroom under the 46k cliff.

* feat(desktop): author-parameter completes the reopen in-call — the key signature no longer stops the song

CODA's one seam, closed tool-side: getExecutor discovers manifests dynamically
and getDesktopConfig() re-reads env per call, so the tool itself relaunches the
seeded stage (open -n, live-probed: manifest 2s, API-ready 3s), readback-verifies
the parameter in the reopened doc, re-pins TABLEAU_DESKTOP_SESSION_ID in-process
(only when a pin already existed), then SIGTERMs the old instance (probed: dies
clean, no recovery sibling). Any failure degrades to the honest reopenRequired
stage-on-disk result. stagePath is now optional — untaught agents never invent
filesystem paths; the default stages beside the live workbook's own file.

* chore: sync package-lock version to 2.26.0

* fix(desktop): reopen launches the bundle binary directly — open -a loses the document among instances

Dress rehearsal caught two live defects the offline suite could not: (1) with
multiple instances of the bundle running, 'open -n -a' starts a fresh instance
but LaunchServices routes the document Apple Event unpredictably — the reopened
instance came up EMPTY; spawning <bundle>/Contents/MacOS/<binary> <stage>
detached carries the document deterministically (and puts it in argv). (2) The
default stagePath cannot derive from the old process's argv for the same reason —
it now stages under ~/Documents/My Tableau Repository/Workbooks (real user path;
Desktop has crashed saving from sandboxed tmp dirs). E2E-proven live: reopened
78197→78328, param verified in the new instance's readback.

* docs(knowledge): author-parameter reopens in-call — the router doc catches up to the shipped verb

* feat(desktop): dynamic-authoring route joins the instructions — the verbs are named where the singer reads first

Finale autopsy: untaught Sonnet landed both parameters through the in-call
reopen but burned 55 of 65 calls spelunking raw commands for the set and
dashboard, and never reached author-set — the tool list carried the verbs but
the initialize instructions never routed a dynamic ask to them. Segno's
discoverability law, one level up: ink the sign in the score she reads at
note one.

* fix(desktop): trim the dynamic-authoring route under the combined-lean 46k cliff

The prior commit pushed combined-lean to 46,116 — the full suite caught it
after an ungated commit chain (the lesson is the gate order, re-learned).

* feat(desktop): dialog blocklist enforced in the param guard — the boo modal dies here

revert-workbook-ui fired Error 47BF7751 on Matt's screen three times in one
verse: the reference says safe, CODA's probe said modal, the knowledge doc knew,
but nothing ENFORCED it. Now the guard refuses all 16 live-proven dialog
commands outright, each with a FIX redirecting to the sanctioned alternative
(author-parameter, author-action, NotionalSpec keys, or author-forward).
Every annoyance dies exactly once.

* fix(desktop): author-calc resolves PARAMETER captions to qualified [Parameters].[N] refs — the empty-sheet killer

Verse-3 live receipt: the singer's filter calc named two parameters by caption;
resolution only covered the target datasource's columns, so the refs never bound
and the sheet rendered empty on Matt's screen. Parameter captions now resolve
from the Parameters datasource to the qualified cross-datasource form, set after
the field map so a caption collision resolves to the parameter — which is what a
dynamic-ask formula means.

* feat(desktop): notionalSpecGuard validates full v0.2 vocabulary — kills the silent-empty-sheet class

Live eval receipt (2026-07-20 TUTTI Blake-set wave): a spec with
aggregation:'none' (not v0.2) and a rangeFilter using max: (v0.2 is
start/end) passed the guard, codegen generated NOTHING, and the /v0
success envelope shipped an empty sheet as a PASS. The guard now
validates per-field keys/enums (data/type/role/aggregation/encoding,
'shelf' redirect, detail/tooltip v0.3 gate), sort, rangeFilters,
relativeDateFilters (plural periods, next/previous), and
categoricalFilters — every rejection carries a FIX line that teaches
the correct vocabulary in-context. Accept fixtures are the six
live-proven TUTTI golden specs; reject fixtures are the live failing
payloads.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(desktop): teach the NotionalSpec dialect in the census entries for the semantic-loop pair

The dynamic-authoring profile has no knowledge tools, so search-commands
is the singer's only reachable teacher — expertise:// pointers are dead
ends there. The generate-viz entry now carries the v0.2 cheat-sheet
(chart enum, field props, filter/sort shapes), the author-calc-then-
reference-by-caption idiom (aggregation 'default', never 'none'), the
string-date DATE() idiom, and the write-blind law (probed raw
2026-07-20: /v0 invokeCommand returns state:SUCCEEDED with no result,
no operations route — verify by readback, always). The read-spec twin's
entry now states its output is NOT returned by the /v0 API (product
gap) and routes readers to document readback. Both entries get real
parameter metadata (was []).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* style: autofix lint (prettier, import-sort) + explicit return type on test helper

Green firsthand: lint clean, build ok, 4531 tests pass. CI was red on lint only.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test(desktop): mock spawnDetached in the 3 reopenFromStage tests that omitted it

CI Tests step failed (both node matrices): the timeout + 2 success-path reopen
tests fell through to defaultSpawnDetached, which spawned the real macOS Tableau
binary — ENOENT on the Linux runner fired as an async unhandled rejection after
the test passed, exiting the process 1. Passed locally only because the app path
exists on macOS. All other reopen tests already mock it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(desktop): the singer sings native by default — unset TOOL_PROFILE now selects the 12-tool dynamic-authoring surface

Flips the desktop default from the full 40-tool surface (raw XML get/apply +
templates + cache) to the lean native-authoring surface: semantic loop via
execute-tableau-command + the five author-* verbs + discovery/readback, zero
agent-visible XML. 'full' is the explicit opt-in escape hatch; demo/spec-loop/
combined-lean unchanged. Consumers that need the XML surface set TOOL_PROFILE=full.
TAS + the eval kit already set the profile explicitly, so the singer is unaffected;
this makes native the DEFAULT for every unset consumer (ghost gen-19 standing move).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test(e2e): desktop + combined list-tools expect the lean native default surface

The TOOL_PROFILE default flip (unset -> dynamic-authoring) changes what both the
standalone desktop server and the combined bundle's desktop half register when no
profile is set. Assert the lean 12-tool native surface (raw XML tools opt-in via
TOOL_PROFILE=full). Desktop list-tools verified locally; combined needs CI web creds.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(desktop): data-loss guard matches double-quoted worksheet names (was failing open)

Review P1: WORKSHEET_NAME_RE only matched name='...' (single quotes), so a live
worksheet with a double-quoted name was invisible to the dropped-sheet check and
could be silently dropped on reload — the exact data-loss this guard exists to
prevent, failing OPEN. Match both quote styles (the pattern formatLabels.ts already
uses for the same attribute). Regression test with a double-quoted live sheet.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(desktop): cut model round-trips — bind-template on the slim surface, coherent instructions, census, live-session fields

Live receipts (2026-07-20 slim battery) root-caused the slow eval: ~9 model
turns/song at ~5s each while every tool result returns in <0.1s. Three of
those turns were manufactured by the surface itself:

- instructions advertised bind-template and XML fallback tools the
  dynamic-authoring profile does not register (m1 receipt: two
  search-commands calls literally hunting for 'bind template'), so
  bind-template joins the slim profile (13 tools) as the deterministic
  ~0.3s fast-path for plain chart shapes; route prose no longer names
  unregistered tools;
- a command census prose entry lists the load-bearing tabdoc/tabui
  commands so search-commands is reserved for the uncommon path (m1: 13
  of 23 turns were discovery thrash);
- list-available-fields required a cached workbookFile and its error
  pointed at cut get-*-xml tools (e1 turn 1 burned on 'File not found');
  workbookFile is now optional and omitting it reads the live session
  workbook.

Instructions stay under the 46k tools/list deferral cliff (combined-lean
budget test green). Full suite 4535 green, tsc clean, lint clean.

Known tradeoff: DESKTOP_INSTRUCTIONS is profile-independent, so full-profile
consumers lose the dashboard-auto-apply routing prose; instructions now match
the default surface. Profile-aware instructions are a follow-up.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(desktop): Call-2 auto-apply + result-carried readback for generate-viz-from-notional-spec

Live re-time receipt (e1, 60s/13 calls): a Call-2 bound result stranded the
slim surface (auto-apply was Call-1-only and the manual apply tools are cut),
costing 4 search-commands thrash turns; then 5 more turns re-verified a
write-blind spec apply.

- bind-template auto_apply now applies any bound fast-path-eligible result.
  A Call-2 proposal is binder-validated against the live workbook and applies
  under the same events-anchor user-change guard; the freehand fallback the
  model otherwise takes has fewer guards.
- tabdoc:generate-viz-from-notional-spec success results now append a compact
  server-side readback (sheet, Rows/Cols shelves, mark, sort) resolved from
  the active window; fail-open, one metadata round-trip. The command
  reference's verify guidance now points at the result readback.
- bind-template byte cap ratcheted down to 1629 (describe trim funded the
  gate-comment rewrite).

Full suite 4537 green, tsc clean, lint clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(desktop): retire the notional-spec dialect from routing — bind-template + author-* carry the song, refine-worksheet joins the slim surface

NotionalSpec is retired (Matt, 2026-07-20). Three live pointers still steered
the model at the dead command (jam gen-21): the plain-chart/dashboard/dynamic/
edit-in-place route prose, the command census, and the success-path hint
strings author-calc and author-set return on every call. All re-pointed at
the post-spec dialect: author the semantic object first, then bind-template
the chart naming the authored caption; refine-worksheet (primitives-only
top-N/sort) joins the 14-tool dynamic-authoring profile to carry
edit-in-place. The spec command itself stays registered and functional —
the door closes when the replacement chain is live-proven, not before.

Full suite 4537 green, tsc clean, lint clean. Instructions +13 bytes,
cliff-safe (refine-worksheet was already in the full surface the combined
budget measures).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(desktop): inline calcs[] on bind-template — author + bind in one call

jam gen-22 Move 1 (receipt: the author-calc→bind chain costs ~9-13s of model
turns per calc song). bind-template accepts calcs[{caption,formula,datatype,
role}]: formulas validate BEFORE any document mutation (atomic error, zero
apply on a bad calc), authoring reuses author-calc's splice/load/readback via
the new shared authorCalcCore, and the bind + auto-apply see the post-calc
workbook. authored_calcs surfaces in every outcome. Route prose updated.
Per-tool cap raised 1591→1725 for the capability (slim surface is ~11.6k of
46k; combined-lean cliff stays the hard gate, green at ~45.9k).

Full suite 4540 green, tsc clean, lint clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(desktop): refine-worksheet sort_by_field + empty session treated as absent

jam gen-21/22 Move 2 (receipt: m1 waterfall — tabdoc:sort 500'd on seven wire
shapes, ~150s of blind model turns, before a manual computed-sort splice
landed direction/using correctly). sort_by_field produces that same
computed-sort shape through refine-worksheet's readback-verified apply path,
with caption resolution matching the existing ops. resolveSession now treats
empty/whitespace session strings as absent (was a wasted error turn:
session:'' against a pinned Desktop).

Full suite 4548 green, tsc clean, lint clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(desktop): tabdoc:sort is dialog-natured — block with redirect; teach the true sort contracts

Live receipts (2026-07-20): tabdoc:sort popped blocking modals on the user's
screen (47BF7751 'missing: global-field-name') after seven opaque 500s. The
monolith's own sort-cmd.data settles it: SortCommand drives the sort UI
dialog (updateSortDialog notification) — it was never agent-safe — and
SortNested is the programmatic sort that existed all along.

- execute-tableau-command now refuses tabdoc:sort pre-dispatch with a FIX
  line redirecting to refine-worksheet sort_by_field or tabdoc:sort-nested.
- Reference corrected: Sort marked dialog-risky with the true wire contract
  (global-field-name, Type-required-unless-clear, computed-sort MeasureName);
  sort-nested pinned with its required params.
- paramContractGuard enforces sort-nested's required params pre-dispatch.

Full suite 4556 green, tsc clean, lint clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(desktop): shared slot vocabulary — sort + top_n on the bind proposal (#555)

* feat(desktop): shared slot vocabulary — sort and top_n on the bind proposal

jam gen-23 Move 1 (receipts: m1's order-by strand; the ranking family cannot
say 'top 10'). The proposal schema gains two optional concepts — sort {by,
direction} and top_n — advertised in PROPOSAL_OUTPUT_SCHEMA so Call-2 models
learn them from the propose payload itself, validated against the workbook
schema BEFORE apply, spliced via the computed-sort/top-N helpers extracted
from refine-worksheet (built earlier today — reused, not duplicated), atomic
failure on splice errors. One bind call now carries ordering and limits; no
refinement tail. Growth law honored: receipt-backed concepts only, binder-
validated, riding the one-call path.

Full suite 4567 green, tsc clean, lint clean. combined-lean 45,992/46,000 —
8 bytes headroom; next growth must trim first.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(desktop): complete the slot-vocabulary commit — validate seam + describe trims

These two files were part of the sort/top_n packet but missed the explicit
staging list; the working-tree suite masked the gap (adjudication lesson:
git-status-clean check BEFORE declaring a packet landed). Without them the
pushed tree fails its own validate-proposal byte cap.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(desktop): literal names at every tool boundary — the ampersand bug dies

Receipt (m1, 2026-07-20): a sheet named 'P&L Waterfall…' surfaced entity-
escaped through list-worksheets, made refine-worksheet miss by literal name,
and silently confirm-miss (refined:false) by escaped name — any &-titled
sheet was unreachable. Now: names decode exactly once at output boundaries
(list-worksheets/dashboards, bind-template sheet_name/guidance), name inputs
resolve normalized-to-normalized with escaped-input tolerance, apply gates
compare normalized, and refine uses the canonical literal name from fetched
XML for apply/readback/output. Coverage for & < " in literal and escaped
paths. (Includes a sliver of the prior commit's file overlap — the two
commits were disentangled from one worktree; suite green across both.)

Full suite 4582 green, tsc clean, lint clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
…penapi (#557)

Tighten the hand-derived Athena V0 contract against the live /openapi.json
(0.1.0, captured 2026-07-20) plus live-probe evidence:

- Operation envelope: operationId -> id, add kind, id/kind/state required,
  warnings[] added; error is now a distinct OperationError {code, message}
  instead of the conflated Problem shape. result stays optional (absent from
  the spec; kept until output-param behavior is confirmed with the API owner).
- Problem: RFC-9457 shape (+instance), PROBLEM_CODES 5 -> the spec's 16-value
  x-extensible-enum. Fields stay optional so error extraction fails open.
- Mock server emits the spec-true wire shapes (incl. 401 code unauthenticated;
  the internal 'unauthorized' variant maps at the status boundary).
- NEW contract-intake harness (externalApiContract.test.ts + captured-spec
  fixture): the next spec drop is a red/green diff keyed off our schemas,
  not a manual reread. Also pins invokeCommand as deliberately undocumented.

Full suite 4600 green; externalApi 58/58; eslint clean.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
#558)

* feat(desktop): route calc/derived-field asks through the author verbs

TAS !54 removed tool-by-name teaching from its system prompt (prompts must not
name tools); the routing's correct home is this server's own instructions. The
dynamic-authoring route trigger now covers calc/derived-field asks, paid for
inside the 46k tools/list byte budget by dropping the redundant census clause.
The full last-resort document round-trip law lives in the calc-fields knowledge
resource (zero tools/list cost). Live routing verification (calc-song re-sing)
deferred to a live-Desktop session.

Co-Authored-By: Claude <noreply@anthropic.com>

* docs(knowledge): purge the retired NotionalSpec dialect from the served corpus

The four notional-spec-*.md files (landed #543, extended #552) taught a dead
command as the primary path — the eval singer's corpus was steering agents at
a corpse. Deleted; every still-true fact relocated stripped of spec framing:
the author-* verb routing map + OPEN/MERGE law to the new
dynamic-dashboard-authoring.md, calc-command pitfalls to calc-fields.md,
column-removal round-trip gotcha to round-trip-normalization.md, error-class
diagnosis ladder to recovery.md. Steering surfaces (search fallback,
commands-reference recommendations, guard FIX pointer) now teach the current
ladder. The defensive guards stay until the spec door is hard-closed.

Co-Authored-By: Claude <noreply@anthropic.com>

* style: prettier line wraps (CI lint)

Co-Authored-By: Claude <noreply@anthropic.com>

* style: prettier line wrap in the guard test

Co-Authored-By: Claude <noreply@anthropic.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>
… errors (#559)

Live eval receipt (m1 waterfall, 2026-07-20): sort-nested passed local param
validation then returned HTTP 500 three times before the singer found the
document round-trip — ~30s and several model turns burned re-guessing.

Error-path teaching only (fail-open: no pre-dispatch block — the server may
fix the command any build): execution errors for mapped commands append a FIX
line naming both blessed fallbacks (bind-template sort proposal; doc round-trip).
Unmapped commands byte-identical.

Suite 4603 green, lint clean.

Co-authored-by: Claude <noreply@anthropic.com>
…er template sort (#560)

* feat(desktop): waterfall anchor_category slot + sort proposal wins over template sort

Live eval receipts (m1 waterfall FAIL 34/100, 2026-07-20): (1) the singer's
sort override 'hit a template limitation' — part-to-whole-waterfall hard-codes
computed-sort DESC by SUM(Profit) and the sort field wasn't declared in
datasource-dependencies, so the proposal was inert; the singer burned 3
sort-nested 500s + a doc round-trip forcing ASC-by-display_order. (2) subtotal/
total rows were running-totaled with the increments they summarize — final bar
~2x true Net Income.

- sort proposal now replaces the built-in computed-sort (declares the sort
  column/column-instance when missing); every failure path is fail-open with a
  warnings[] channel on the bind result — template default kept, bind never blocks
- new optional virtual slot anchor_category: when bound, splices a categorical
  EXCLUDE filter (subtotal/total) so anchors leave the bridge; unbound binds are
  byte-identical (characterization-pinned)
- teaching lives in the manifest slot description (model-facing at bind time),
  NOT tools/list: bind-template/validate-proposal ratchets TIGHTENED 1908->1886 /
  1555->1533; combined-lean 45,952 of 46,000
- served-corpus knowledge twins: waterfall subtotal/total law + zero-denominator
  ratio-sort guard (both analytics paths — legacy twin is live)

Suite 4608 green (316 files), lint clean, invariants unchanged. v2.27.0.

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(desktop): warnings typing on result base + result-carried waterfall discovery hints

CI tsc caught warnings? missing on BindTemplateToolResultBase (vitest
transpiles without typecheck — the gate that matters is npx tsc).

Live re-sing receipt (m1, judge 34 both pre/post-slot): the slot shipped but
was never FOUND — a confident bind has no Call-2, so manifest descriptions are
never read, and the routeTable teaching was trimmed for the 46k cliff. A
capability without a discovery channel is a no-op.

Result-carried teaching (the #554 pattern, zero tools/list bytes): waterfall
bind results now hint (a) the anchor_category re-call naming the schema's
category-like column when the slot is unbound, (b) the one-call sort override
when no sort proposal was passed. 6 tests pin presence/absence per case.

tsc 0, suite 4616 green (316 files), lint 0 — pipe-proof exits.

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(desktop): sort splice handles the serializer's paired-empty computed-sort form

Third live sing receipt: the singer finally passed the full correct proposal
(anchor_category + sort by Display Order asc) and the splice refused it —
'only the safe self-closing <computed-sort/> can be changed'. Root cause: the
SOURCE template is self-closing but inject serialization expands it to
<computed-sort ...></computed-sort>; the safe-form detector missed our own
serializer's output.

Safe target detection now covers self-closing, paired-empty, and plain-wrapper
forms; truly nested <sort-computation>/manual forms still refuse (the
computed-sort-crash class — Desktop crash guard intent preserved, evidence in
computedSortCrash.ts). bind-template and refine-worksheet sort_by_field share
the helper, so both inherit the fix. Characterization test pinned to the real
inject-serialized waterfall shape (failed before, passes after).

tsc 0, suite 4620 green (316 files), lint 0 — pipe-proof exits.

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(desktop): waterfall seam-4 — sort the shelf axis with anchor_category bound; surface knowledge tools

Two coupled fixes so a P&L waterfall can carry BOTH an anchor exclude-filter
AND a proposal sort, and so the singer can actually reach the corpus that
already documents how.

Seam 4 (planSortByFieldOnCategoricalAxis): the categorical-axis count included
filter-only dimension CIs. A waterfall's anchor_category exclude-filter adds a
nominal/None CI that lives only in <filter>; it was counted as a rival axis and
tripped "more than one categorical axis is present; sort is ambiguous." Restrict
axis candidates to dims whose DS-qualified ref appears on a rows/cols shelf,
mirroring planSortInsertion's proven membership check. Substring membership is
exact for CI refs (the :suffix] grammar means one ref is never a substring of a
longer one; ds + ci.name share the shelf text's escaping domain, e.g. P&L Data).
Fail-before/pass-after test uses the real part-to-whole-waterfall template +
spliceWaterfallAnchorFilter; adds a prefix-boundary case (RegionName vs Region)
and keeps the genuine two-shelf-axis refusal.

Knowledge tools: the default dynamic-authoring profile omitted
list-knowledge-resources + read-knowledge-resource, yet the singer's system
prompt instructs "consult the expertise library BEFORE authoring" — so the
corpus (which already states the waterfall subtotal/total exclusion rule and the
Top-N-needs-a-context-filter rule) was unreachable on every sing. Add both to the
profile (14→16) and sharpen their descriptions to say WHEN to reach (net -27
bytes; combined-lean tools/list surface stays under the 46k cliff with more
headroom than before). No a2td port owed — a2td's refine is a different
(operation-based) architecture and always registers the knowledge tools.

Gates: tsc 0, vitest 4623 passed/1 skipped, lint 0. Andy-review addressed.

Co-Authored-By: Claude <noreply@anthropic.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>
* feat(desktop): waterfall bind hint names the step-order column (routes off-view sort into the bind)

Live m1 receipts (N=3: 83 PASS / 38 FAIL / 82 PASS): the FAIL and the expensive
PASS both came from the same seam — the singer bound the waterfall plain, then
tried refine-worksheet to sort by display_order, which refuses ("unknown sort-by
field") because refine can only sort by a field already on the view. Run 2 gave
up (magnitude order → wrong bridge); run 3 forced it with two rounds of
load-underlying-metadata XML surgery.

The bind ALREADY supports proposal.sort:{by:"display_order"} (ensureSortByColumnDependency
injects the off-view column; tested). The gap was discoverability: the generic
WATERFALL_SORT_HINT said "override with proposal.sort:{by:<field>}" — too vague to
connect to the sequence column. Now, when the schema carries an explicit
sequence/order column (display_order, sort_order, ordinal, …, matched precisely —
line_item and order_count do not false-positive), the hint NAMES it and prescribes
the exact re-call, and says plainly not to use refine (it can't sort off-view).
Mirrors the working anchor_category discovery hint. Falls back to the generic hint
when no order column exists.

Gates: tsc 0, bindTemplate suite 53 passed, lint 0.

Co-Authored-By: Claude <noreply@anthropic.com>

* feat(desktop): waterfall anchor hint is imperative + evidence-grounded (kill the hedge)

Live m1 receipt (run 5, FAIL 42): the singer got the order right but its OWN closing
report said "I didn't bind the Category field in… let me know if that's the case and
I'll wire Category in as an anchor to fix it." The data plainly had a category column
with subtotal/total rows; the running total double-counted; groupfilter function=except
count in the whole receipt was 0. The anchor hint's advisory tail ("totals double-count")
read as a WARNING the singer could hedge on, not a directive.

Reword the anchor discovery hint to be imperative and grounded in the schema evidence:
"schema has <col> — a row-type column means this P&L data almost certainly has
subtotal/total rows the running total WILL double-count. Re-bind NOW … do NOT ask the
user or leave it unbound." Advisory wording invites the hedge; a data-grounded directive
closes it. Companion to the step-order hint in this branch.

Gates: tsc 0, bindTemplate suite 53 passed, eslint 0.

Co-Authored-By: Claude <noreply@anthropic.com>

* feat(desktop): waterfall bind DEFAULTS the step order to a sequence column

The deterministic completion of the waterfall-hints work. Live m1 receipts (N=3
post-hints): the singer lands the display_order sort in the confident bind only ~1/3
of runs (run 6 PASS 90); the other runs bind plain, attempt the sort at an
intermediate state where it doesn't resolve, and settle for the template's
DESC-by-measure default (runs 7/8 FAIL 43/48, order gate missed) — a build-SEQUENCE
nondeterminism, not a missing capability (the final shape sorts fine, proven against
the recovered stage .twb).

Fix the sequence problem by making the ONE confident bind complete: in the shared
validateAndBuild, when the template is part-to-whole-waterfall AND no usable sort was
proposed AND the schema carries a sequence/order column (display_order/sort_order/…,
matched precisely — Order Date and measures do not false-trigger) that resolves
unambiguously, default proposal.sort to that column ascending, with a warning naming
it. An explicit proposal.sort still wins; no order column → template default kept
(never guess). Waterfall-only guard; 683 other binder tests unaffected.

Gates: tsc 0, full vitest 4627 passed, lint 0. 3 new tests (defaults with order col /
no default without / explicit override).

Co-Authored-By: Claude <noreply@anthropic.com>

* refactor(desktop): share one WATERFALL_ORDER_FIELD_RE; name rejected order candidates

Andy-review response on the waterfall-order work:
- De-duplicate the sequence/order-column regex: export it once from binder.ts (the
  apply side) and import it into bindTemplate.ts (the hint side) instead of two copies
  that could drift. bindTemplate already imports from binder, so no layering violation.
- When >1 sequence column matches, the default-sort warning now names the rejected
  candidates ("also matched: sort_order, seq") so the agent/user can correct the
  deterministic first-match pick.

Gates: tsc 0, full vitest green, lint 0. Behavior unchanged (same regex, same first-match
default); this is clarity + drift-safety.

Co-Authored-By: Claude <noreply@anthropic.com>

* feat(desktop): waterfall bind DEFAULTS anchor_category to a row-type column

The reliability completion of the m1 waterfall work. Live receipts (N-run): even with
the sort default + imperative anchor hint, the singer landed the subtotal/total EXCLUDE
filter only ~half the runs (hedged or skipped anchor_category), so m1 dropped a different
gate run-to-run. Make the anchor deterministic the same way as the sort: in the shared
validateAndBuild, when template=waterfall AND no anchor_category was bound AND the schema
has a category/row-type dimension (categor|type|kind|class|flag|marker) not already bound
to another slot, inject the anchor binding BEFORE validation — it resolves through the
normal path into field_mapping['Anchor Category'] → spliceWaterfallAnchorFilter (the
subtotal/total exclude). Fail-open: if the added anchor breaks validation, drop it and
bind the caller's original (never turn a good bind into an escalation), with a warning.
No row-type column → no anchor. Explicit anchor still wins.

With sort + anchor both defaulted, m1 now passes all three gates deterministically:
N=3 clean live PASS (84/90/85, anchor except-filter x3 each, all gates green).

Gates: tsc 0, full vitest 4629 passed, lint 0. 2 new binder tests (defaults w/ row-type,
no-default without).

Co-Authored-By: Claude <noreply@anthropic.com>

* refactor(desktop): honest anchor-default warning + share the anchor regex/slot-id (review)

Andy-review of this PR surfaced three real items in the anchor auto-default:

1. (open-question, fixed) The success warning asserted "excluded subtotal/total
   rows" but spliceWaterfallAnchorFilter only excepts members literally named
   "subtotal"/"total" — on a plain waterfall whose auto-bound category has no such
   members the filter is inert, so the warning claimed an exclusion that never
   happened. Reworded to describe what was ADDED (a filter that excludes any
   subtotal/total members), not an exclusion that occurred. Agent-facing honesty.
2. (nit, fixed) WATERFALL_ANCHOR_SLOT_ID / WATERFALL_ANCHOR_FIELD_RE were declared
   identically in binder.ts AND bindTemplate.ts — the exact drift risk this PR
   eliminated for WATERFALL_ORDER_FIELD_RE. Now exported from binder.ts and
   imported in bindTemplate.ts, one definition each.
3. (nit, fixed) Replaced the overloaded 'FAILED' string sentinel on
   defaultedAnchorField with a dedicated anchorDefaultFailed boolean.

binder.test.ts: the anchor-default warning assertion now checks the honest wording.
All 98 binder/bindTemplate tests + 23 server.desktop guards (byte-cliff, vocab) green.
Version 2.27.0 -> 2.28.0 (this PR was missing its bump).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>
… definitions (#563)

Two served-corpus additions surfaced by the live m1/h6 eval cycle (the singer now
reads knowledge — it's on the dynamic-authoring profile as of #560):

- advanced-chart-builds waterfall bullet: the running total is order-dependent, so
  when the intended P&L order is a NON-displayed sequence field (display_order), carry
  it in the ORIGINAL bind (proposal.sort:{by,direction}) — refine-worksheet cannot sort
  by a field not on the view. Kills the m1 "sorted by magnitude" miss when the singer
  reaches knowledge before building.
- new strategy/analytics/profitability-margin-definitions: gross = (revenue - COGS)/
  revenue; operating subtracts opex; net subtracts everything; contribution subtracts
  only variable cost. The h6 song fails if the singer folds opex into a "gross margin"
  ask. Arithmetic (ratio-of-sums, zero-guard) already lives in the aggregate-ratio +
  calc-authoring entries; this pins WHICH costs each margin subtracts.

Both pass house-format (13617 / 6238 chars, all required sections). a2td source carries
the margin entry on claude/h6-margin-knowledge; this vendors both into the tmcp served
corpus the desktop singer actually reads.

Co-authored-by: Claude <noreply@anthropic.com>
…ontinuous date axis (#565)

* feat(desktop): temporal_axis_from_string — bind a string month to a continuous date axis

The judge's suggested_fix for e4 (MAU-over-time): the trend-line template's
order_date slot requires a real date/datetime, but e4's dataset carries a STRING
"2025-08" month, so the bind escalated with kind-mismatch and the singer fell back
to value-sorted bars (summing both products) or dead-ended asking the user.

This adds an opt-in path — only for a temporal slot that sets temporal_from_string
(trend-line-chart's order_date) — that accepts a date-like string source and, on the
apply side, injects a DATEPARSE calc so the Month-Trunc axis truncates a real parsed
date instead of the raw string. One confident bind now renders a continuous month
line off a string month.

Pieces:
- stringTemporal.ts: inferStringTemporal — accepts a STRING dimension whose NAME is
  date-like (month/date/period/…) and infers a DATEPARSE format (yyyy-MM for month,
  yyyy-MM-dd otherwise). Fail-closed: a non-date-like name stays a kind-mismatch.
- validate.ts gate 3: the temporal kind-mismatch is waived for an opted-in slot whose
  string field inferStringTemporal accepts; the slot's field_mapping key is then
  SKIPPED (the apply splice owns its XML) and a DateparseAxisSpec rides out on the
  ValidateResult → InjectTemplateArgs.
- dateparseTemporalAxis.ts: apply-side splice (modeled on waterfallAnchorFilter) that
  converts the template's date base column into a DATEPARSE calc and declares the
  bound string source column, leaving the Month-Trunc CI + every shelf/format ref
  byte-unchanged (no repoint, no duplicate-CI risk). Identity no-op when no axis
  requested; fail-closed if the base column can't be converted.
- trend-line-chart manifest: order_date opts in (temporal_from_string:true); index
  regenerated.

Tests: 9 splice + 6 inferrer + 2 end-to-end (real trend-line template → coherent
DATEPARSE calc + intact namespaced Month-Trunc axis; real-date path byte-identical).
Full binder/templates/server suites green (858), byte-cliff unchanged (feature adds
no tool-surface bytes), tsc + eslint clean. Version 2.27.0 -> 2.30.0.

NOT YET LIVE-VERIFIED: the DATEPARSE format is inferred from the field NAME (the binder
sees schema, not cell values), and a wrong format renders a blank axis. The opt-in is
enabled on trend-line but MUST be confirmed with a live render against e4's real
"2025-08" data before it is trusted in production — XML validity is proven, value
parsing is not.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(desktop): surface trend-line-chart for natural time-series phrasings (e4 discoverability)

The live e4 sing proved temporal_axis_from_string was UNREACHED: the singer never
proposed trend-line-chart for "Show me monthly active users over the last 12 months"
— that ask scored ZERO whole-token hits against the template's intent_keywords
(over-time ≠ "over the last", by-month ≠ "monthly", "line" absent), so trend-line
never surfaced as a Call-1 candidate. The singer guessed a nonexistent "line"
template, got the general-tools prefill, and fell back to magnitude-simple-bar
(bars, both products summed) → FAIL.

Add the phrasings a natural time-series ask actually uses: monthly, weekly, daily,
per-month, over-the-last, last-12-months, over-months, active-users, mau, dau,
line-chart. Probe confirms trend-line-chart now surfaces as a candidate for the MAU
ask (was absent). 688 binder/classifier tests green — no keyword-hijacking regression
on the other songs.

This is the discoverability half of the e4 fix; the temporal_axis_from_string slot
(prior commit) is the capability half. Both are needed: the singer must reach
trend-line AND be able to bind the string month to order_date.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…timeout (#567)

fix(ci): widen vitest teardown window to stop the flaky onTaskUpdate RPC timeout

CI's Tests step went red on #564 with `[vitest-worker]: Timeout calling
"onTaskUpdate"` thrown as an UNHANDLED error AFTER all 317 test files passed —
vitest then exits 1 on the unhandled error, a flaky red on green code. It's a
pool→reporter RPC starvation under a ~157s parallel run (the final task-update
exchange times out under load), not a test failure: #565 ran the same suite and
passed both Node jobs.

Set `teardownTimeout: 30_000` in the shared vitest config so the teardown/RPC
window is wide enough for the final reporter exchange to complete under CI load.
Config-only; local run still green, tsc + lint clean. Version 2.29.0 -> 2.29.1.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…564)

* feat(desktop): search-knowledge tool — targeted top-N over list-all (fixes over-consultation)

Adds a `search-knowledge(query, limit=5)` desktop tool that ranks the expertise
corpus with fuse.js and returns only the top few relevant modules (uri + title +
snippet), ported from the a2td side. This replaces the discover-then-read path
that forced the model through `list-knowledge-resources` (an unbounded dump of all
URIs) before it could find anything.

Why: the ETUDE A/B found that surfacing the raw list+read tools REGRESSED a hard
ask (e4, string->date calc) PASS->FAIL — the model consumed the entire 108-entry
corpus instead of the one relevant module (fragility ledger Shape 10). A targeted
search returns the 1-2 modules that matter, so a hard ask gets guidance without a
wholesale-read spiral.

- searchKnowledge() in desktop/knowledge/index.ts: same weighted-key fuse config
  as a2td search.ts (searchTerms .3 / tags .22 / title .2 / whenToUse .18 /
  slug .06 / body .04, threshold .4), lazy-built + cached index, skips _-meta files.
- registered on toolName, tools, and the DYNAMIC_AUTHORING_TOOL_PROFILE.
- list/read descriptions now steer to search-knowledge first; trimmed cache/validate
  descriptions to keep the tools/list surface under the 46k ToolSearch cliff.
- knowledge.test.ts: relevance assertions (target entry ranks #1 for its ask;
  no cross-capture between sibling entries).

Version 2.27.0 -> 2.28.0 (new tool).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore(desktop): bump to 2.29.0 (avoid 2.28.0 collision with #562)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(knowledge): collapse it.each relevance cases into loop-driven blocks (unflake #564 CI)

* test(knowledge): raise suite timeout for the genuinely-slow relevance searches (fix #564 CI)

Root cause of #564's deterministic CI red (found after the it.each collapse made
it surface as a real failure instead of an unhandled RPC error): each fuse.js
search scans the full document `body` of the ~108-doc corpus with
ignoreLocation:true — ~450ms/query, real work, not a leak. A multi-case relevance
block runs several such searches, so on a slow/contended CI worker it exceeds
vitest's 5s DEFAULT testTimeout ("Test timed out in 5000ms"), and the hung test
also starves the pool's onTaskUpdate reporter RPC (the earlier "flaky" symptom).
Local runs just squeak under 5s, which is why it passed here and failed in CI.

Fix: `describe('knowledge/search', { timeout: 30_000 }, ...)` — give the honestly-
slow relevance blocks headroom on CI. No production code changed; ranking behavior
and coverage (44 query→suffix pairs) are untouched. Local: 13 tests pass; tsc + lint clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* perf(knowledge): drop full-body fuse key (9x faster search) — the real #564 CI fix

ROOT CAUSE of #564's CI failure (found after ruling out timeouts): fuse.js searched
the full document `body` of the ~108-doc corpus with ignoreLocation:true — ~450ms
PER QUERY, the dominant cost. knowledge.test.ts's ~44 relevance searches took ~18s,
CPU-blocking a vitest worker past the pool's hardcoded 60s onTaskUpdate reporter-RPC
deadline under parallel CI load (the "flaky"/timeout symptoms were all downstream of
this). No RPC-timeout config knob exists, so the fix is to make search actually fast.

- src/desktop/knowledge/index.ts: remove `body` (weight 0.04) as a fuse SEARCH key.
  Ranking is driven by curated metadata (searchTerms/tags/title/whenToUse/slug); body
  added negligible signal. `body` is KEPT on the doc for the result snippet. Search
  drops from ~450ms to ~ms/query; the test file runs 18.6s → 2.0s.
- one relevance case regressed (the gantt query matched only in body prose) → carried
  its exact phrasing into the doc's curated searchTerms, restoring the #1 rank the
  right way (metadata, not full-text). All 44 relevance pairs green; 13 tests, 2.0s.

This also speeds the PRODUCT search the singer uses — a real improvement, not just a
test fix. (a2td source-of-truth copy of the searchTerm needs the same port — follow-up.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(desktop): route cache-mutation tools through resolveSession so sidecars honor the session pin

The four cache-mutation tools (write-cached-xml, add-field, remove-field,
inject-template) stamped the fingerprint sidecar with the raw agent-supplied
session arg instead of the resolved pin. When TABLEAU_DESKTOP_SESSION_ID was
set, a mismatched or absent session arg produced a sidecar fingerprinted to the
wrong instance, so downstream apply-* tools either failed closed with
CacheSessionMismatchError or bled cache across instances.

Each tool now resolves the session via resolveSession() at callback start,
rejecting a conflicting explicit session and stamping the sidecar with the pin.

* test(desktop): make pin-wins tests distinguish resolved pin from raw session arg

The positive 'stamps the sidecar with the pinned session' tests defaulted the
requested session to SESSION, which equals the pin, so the assertion held
whether the code stamped the pin or the raw arg. The helpers now preserve an
explicitly-undefined requested session (distinct from the pin) while still
defaulting to SESSION when omitted, so a regression to stamping the raw arg
fails the test.
One-page pointer for the Pulse/Chiron flow: how an insight card becomes a
rendered viz via one deterministic bind-template call — card-field → slot-kind
mapping, a routing table over the render-verified launch templates, the
carry-the-proposal-in-the-card recommendation, and the shared active-sheet →
datasource LUID dependency.

Co-authored-by: Claude <noreply@anthropic.com>
…tasource-luid) (#570)

* @W-23202034: Prevent sign-out teardown from masking the real REST error (#470)

useRestApi signs out ephemeral (pat / direct-trust) sessions in a `finally`
block. Because a throw inside `finally` replaces whatever the `try` was
returning or throwing, an un-caught sign-out failure clobbered the callback's
real outcome: a callback that 404s on a missing resource surfaced to the caller
as the sign-out's 401 once the session was torn down. That is the misleading
"delete-workbook returns 401 for a non-existent workbook LUID" report.

Sign-out is best-effort cleanup (the ephemeral session expires on its own), so
isolate it in its own try/catch and swallow-and-log any failure at 'warning'.
The callback's result or error now always reaches the caller intact.

Tests: two unit cases in restApiInstance.test.ts inject a rejecting signOut and
assert the callback's error (and a successful result) survive teardown. Both
fail without the fix. e2e is N/A — the defect only manifests when signOut itself
fails at teardown, which a live run cannot force deterministically.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* @W-23202054: Stop get-stale-content-report from silently ignoring invalid projectIds (#473)

* @W-23202054: Stop get-stale-content-report from silently ignoring invalid projectIds

get-stale-content-report accepts a projectIds list to scope the report.
Two resolution paths silently dropped requested IDs:

1. resolveProjectScopeIds dropped IDs outside the server's bounded context
   (INCLUDE_PROJECT_IDS) with no signal.
2. resolveProjectIdsToNames intersected requested IDs against real site
   projects; IDs matching no project vanished.

Worst case: when EVERY requested ID was invalid, the scope collapsed to
empty and buildSiteContentQuery treated it identically to "no scope" —
returning the ENTIRE site instead of nothing. A typo in a project scope
silently widened the report to all content.

Fix:
- Track dropped IDs through both resolution paths (unknown-on-site vs
  not-permitted-by-config) and surface them in mcp.warnings, reusing the
  result convention established by query-datasource.
- Widening guard: when a scope was requested but nothing resolved to a real
  project, return an empty report (0 rows) plus warnings — never fall
  through to the unscoped full-site query.
- Back-compat: no mcp key is emitted when all requested IDs are valid.

Test coverage: partial-invalid (scoped to valid subset + warning),
all-invalid (empty report, asserts no VDS query fires), bounded-context
drop, all-valid back-compat, plus direct unit tests for the new resolver
return shapes and warning assembly.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* @W-23202054: Branch warning wording on whether a valid scope remains; drop env-var name from output

Addresses PR review (Matt):

1. The ignored-projectIds warning message was unconditional — "The report was
   scoped to the remaining valid projects." fired even in the all-invalid case,
   asserting a valid subset that does not exist. That false premise is exactly
   the silent-widening confusion this fix targets. The message now branches on
   whether any requested project survived: when none do, it states an empty
   report (0 rows) was returned instead of the full site, giving an LLM caller
   a self-correction signal.

2. The not-permitted-by-config message no longer names the INCLUDE_PROJECT_IDS
   env var — "outside this server's configured project scope" carries the same
   signal for a caller without coupling tool output to a config key. (The docs
   page still names the env var; that prose is admin-facing config reference.)

Tests: added buildProjectIdWarnings cases for both message branches and the
env-var-not-leaked assertion; strengthened the all-invalid callback test to
assert the empty-report wording.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* @W-22030892: Add friendly client-name mapping for OAuth client_id values in UIP telemetry (#479)

* @W-22030892: Add friendly client-name mapping for OAuth client_id in tool telemetry

Bearer clients now emit two additive tool_call properties:
oauth_client_id (raw CIMD-URL client_id, canonical) and
oauth_client_display_name (host-keyed static map -> Claude/Cursor/VS Code;
unknown clients fall back to the raw value; absent auth emits '' matching
existing optional-prop style). Telemetry/display only — no auth or
request-handling changes; no network CIMD fetches on the hot path.

Known-host map is best-effort (repo has no observed production client_ids);
fail-safe by construction — wrong/missing entries surface raw, never
corrupt. Single edit point: knownClientDisplayNamesByHost.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* @W-22030892: bump version to 2.25.3

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* @W-22030892: cover embedded-OAuth client ids + bound telemetry values

Self-review round fixes:
- embedded-OAuth sessions (tableauAuthInfo normalized to X-Tableau-Auth)
  carry the client id on extra.authInfo.clientId — telemetry now derives
  Bearer clientId ?? authInfo.clientId (RED-verified: the path emitted
  blanks before).
- client_id is an attacker-influenceable token claim: new
  sanitizeClientIdForTelemetry bounds it (URL -> origin+pathname, drops
  search/hash/userinfo; 200-char cap) on both emitted fields, following
  the accessTokenValidator claim-truncation precedent (divergence from
  its 256+marker documented in the module header).

150 files / 2,246 tests green, tsc + eslint clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

* @W-23149256 prerelease-aware npm + Docker publish (hotfix without moving latest) + release runbook (#416)

* @W-23149256 prerelease-aware npm + Docker publish (hotfix without moving latest) + release runbook

Formalizes the hotfix release path so a livesite fix can ship off an
already-deployed version without dragging main drift or moving the latest
dist-tag. Andy's spec; continuity-critical (release-process holder departs 07-02).

- publish.yml: prerelease-aware npm publish. A version with a SemVer prerelease
  identifier (a '-', e.g. 2.18.0-hotfix.1) publishes under a 'hotfix-v<version>'
  dist-tag, never latest; stable path unchanged (bare npm publish). Fails CLOSED
  if the version can't be read (refuses to risk moving latest). dist-tag is NOT
  the bare version — npm rejects a dist-tag that parses as valid SemVer.
- docker-publish.yml: Docker 'latest' skips prerelease tags (mirror guard), so a
  hotfix gets its exact-version Docker tag but doesn't steal latest.
- docs/RELEASE.md (new): normal + hotfix runbook (branch off tag, fix-on-main-
  then-cherry-pick, bump to -hotfix.N, release from hotfix branch, pin exact
  version in HF) + rationale; linked from CONTRIBUTING.

Pre-push dual self-review (GPT-5.5 + Opus) caught 3 blockers on local code before
this commit: npm rejecting a valid-SemVer dist-tag (fatal), Docker latest moving
on hotfixes, and fail-open on empty version. All fixed here.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* @W-23149256 preflight: release tag must equal v<package.json version> + runbook clarifications

Review follow-up (self-review + Jaehun's walkthrough ask): npm decides
prerelease-vs-stable from package.json while Docker decides from the release
tag name — a tag/version mismatch could move Docker 'latest' while npm holds
(or vice versa). Both workflows now fail closed on mismatch before publishing;
the tag name reaches the scripts via env, not inline interpolation. Runbook:
Release-publish (not tag push) is the trigger, mark hotfix releases
pre-release, and rerun-after-partial-failure semantics (npm immutability).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

* @W-23375797: consolidate admin-gated tools into polymorphic dispatchers (#475)

* @W-23375797: consolidate admin-gated tools into polymorphic dispatchers

Two new polymorphic tools reduce the model-visible admin tool surface:

- `query-admin-insights` (kind discriminator) merges 4 insight readers:
  get-stale-workbooks, get-stale-datasources, get-content-usage-metrics,
  get-user-activity.
- `delete-content` (resourceType discriminator) merges 3 preview→confirm
  destructive tools: delete-workbook, delete-datasource,
  delete-extract-refresh-task.

Behavior preserved end-to-end. `delete-content` records
AppApprovalEvidence under legacy namespaces so the mcp-apps HITL panel
flow (flag-ON) continues to dispatch confirm-delete-workbook /
confirm-delete-datasource / confirm-delete-extract-refresh-task via
the existing iframe machinery. The flag-OFF preview→confirm tag/nonce
evidence gates work unchanged.

Auth: scope-based auth in scopes.ts adds delete-content and
query-admin-insights, each carrying the union of its component tools'
MCP + REST scopes. Both are dropped from `getEnabledToolNames()` when
`ADMIN_TOOLS_ENABLED=false`.

Legacy tools remain wired during the shim window.

Bumps package version 2.25.0 → 2.26.0.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* @W-23375797: fix self-review findings on consolidated tools

- delete-content: replace three per-resource MCP scopes with the
  umbrella tableau:mcp:content:delete so authMiddleware's AND-enforcement
  doesn't 403 callers who only granted one legacy scope. Callers who
  need per-resource granularity keep using the legacy delete-{workbook,
  datasource,extract-refresh-task} tools during the shim window.
- query-admin-insights: move the missing-query check inside
  logAndExecute so failed invocations are logged and product-telemetry
  emitted instead of being silently dropped by the pre-callback return.
- query-admin-insights: honor per-kind MAX_RESULT_LIMITS entries for
  the four legacy admin-insights tool names by taking the tightest of
  consolidated cap, legacy cap, and caller-provided limit — prevents
  operators' existing cap config from silently disappearing on migration.

* @W-23375797: adapt consolidated stale-content to new resolver shapes

PR #473 changed getStaleContentReport's helpers to return richer objects:
- _resolveProjectScopeIds now returns {scopeIds, boundedOutOfScopeIds}
- _resolveProjectIdsToNames now returns {names, unknownIds}
- new _buildProjectIdWarnings helper + _StaleReportWarning type

Mirror the legacy get-stale-content-report tool in queryAdminInsights so
projectId warnings and the all-invalid widening guard behave the same via
kind=stale-content. Re-export the two helpers + type from getStaleContentReport
so both tools share one implementation.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* @W-23375797: fix e2e tool-list test for gated consolidated tools

The consolidated query-admin-insights and delete-content tools are
disabled unless ADMIN_TOOLS_ENABLED=true, so they don't appear in the
server's tool list in the default e2e run. Add them to the
adminOnlyTools filter in the default and combined variant "should list
tools" tests, matching how the legacy per-kind admin tools are already
filtered.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* @W-23375797: lock in tightest-cap via legacy MAX_RESULT_LIMITS

Add a queryAdminInsights.test.ts case that sets
MAX_RESULT_LIMITS=query-admin-insights-ts-events:5, invokes the
consolidated tool with kind=ts-events and limit=100, and asserts the
underlying VDS query is issued with rowLimit=5 — so the legacy per-kind
cap still wins after callers migrate to query-admin-insights. Guards
against the silent-migration hazard flagged in the self-review.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* @W-23375797: fix oauth-embedded scopes_supported count for umbrella scope

The new tableau:mcp:content:delete umbrella scope (added by the
self-review fix on delete-content) bumps scopes_supported from 14 to
15. Update the three well-known-endpoint tests to expect the new scope
by name and the new length.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* @W-23375797: address review — trim descriptions, type logAndExecute, document flag-ON path

- Trim query-admin-insights description from ~546 to ~120 tokens; remove
  inline JSON schema + per-kind param tables (Major, Alon).
- Trim delete-content description from ~437 to ~100 tokens; remove
  per-resourceType parameter reference (Major, Alon).
- Document that flag-ON preview path writes both TagEvidence AND
  AppApprovalEvidence in all three resource branches (Minor, Alon).
- Add explicit <QueryOutput> type param to raw-VDS logAndExecute for
  symmetry with <StaleContentResult> branch (Minor, Alon).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* @W-23375797: export QueryOutput type from adminInsightsToolBase

The previous commit imported QueryOutput in queryAdminInsights.ts from
adminInsightsToolBase.js, but that module only used it locally without
re-exporting. Add `export type { QueryOutput }` so the consolidated
tool's explicit type param compiles.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs: add documentation pages for query-admin-insights and delete-content

- docs/docs/tools/admin-insights/query-admin-insights.md — consolidated tool
- docs/docs/tools/content/delete-content.md — consolidated tool
- docs/docs/tools/content/_category_.json — sidebar category
- docs/docs/intro.md — 2 new table rows

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* @W-23415681: make FeatureGateProvider.isFeatureEnabled async (#497)

* @W-23415681: make FeatureGateProvider.isFeatureEnabled async

* @W-23415681: await async isFeatureEnabled in get-embed-token authz test

* @W-23415681: prune redundant feature-gate init tests

* @W-23375797: remove deprecated admin tool shims (Phase 2) (#498)

* @W-23149256: fix publish.yml startup failure from empty ${{ }} in run: block (#506)

The prerelease-aware comment block added in #416 included an empty
${{ }} expression inside a run: | script. GitHub Actions evaluates
${{ }} in run: text before the shell sees it, so the shell-comment #
does not protect it; the empty expression is invalid and the workflow
fails to load (startup failure, no publish job ever runs). Reword the
comment to drop the token. actionlint clean; publish logic unchanged.

* @W-12345678 Docs update 7-14 (#509)

* 7/14 docs

* version bump

* @W-22350774: Add Tableau Prep flows tools (list-flows, get-flow) (#344)

@W-22350774 Add Tableau Prep flows tools (list-flows, get-flow)

Add two read-only MCP tools that wrap the public Tableau REST Flows API:

- list-flows: lists Prep flows on a site with field:operator:value
  filtering (createdAt, name, ownerName, projectId, projectName, updatedAt)
  and sorting. Every successful response includes an always-present
  mcp.resultInfo { returnedCount, truncated, truncationReason?,
  totalAvailable? } so a caller can distinguish a complete list from one
  cut short by the caller's limit or the admin MAX_RESULT_LIMIT cap.
  totalAvailable is surfaced only when no server-side bounded context
  (PROJECT_IDS/TAGS) is configured, since that count is taken before the
  tool's allow-list filtering.
- get-flow: retrieves a single flow with optional connections and run
  history. Sidecar OAuth scopes are requested only when the corresponding
  sidecar is included, and access is gated by the bounded-context resource
  checker (isFlowAllowed) so PROJECT_IDS/TAGS restrictions apply. Run
  history is bounded by flowRunLimit and emits a FLOW_RUNS_TRUNCATED
  warning when more runs exist.

Supporting changes:
- Gate all flow tools behind FLOW_TOOLS_ENABLED, disabled by default; set
  it to "true" to register list-flows and get-flow (mirrors
  ADMIN_TOOLS_ENABLED). Individual tools can still be excluded via
  EXCLUDE_TOOLS.
- Add the flow REST API definitions, Zod types, and methods, plus the
  dedicated tableau:flows:read, tableau:flow_connections:read, and
  tableau:flow_runs:read scopes.
- Harden parseAndValidateFilterString bracket / in: list handling
  (repo-wide fix).
- Make project.description optional (Tableau returns it on embedded
  <project> elements).
- Tools, types, unit tests, a description-quality eval test, and docs for
  both tools.

* @W-23375797: fix unawaited async feature gate in confirmDeleteContent (#513)

* fix: await async isFeatureEnabled in confirmDeleteContent disabled field

PR #497 made isFeatureEnabled async but this call site was not updated,
causing `!Promise` to always evaluate to `false` — the tool was never
disabled when mcp-apps was OFF.

Wrap in Provider(async () => ...) to match confirmUpdateCloudExtractRefreshTask.
Update test mocks from mockReturnValue to mockResolvedValue.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* chore: bump version to 3.1.1

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(test): remove flow:read from OAuth scope assertions

Flow tools are gated behind FLOW_TOOLS_ENABLED (off by default), so
tableau:mcp:flow:read is never advertised in scopes_supported. The
assertions were incorrect as shipped by the flows PR.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* @W-23388458 - Add insight-cards MCP tool (deterministic insights from datasource context) (#502)

* @W-23388458: Add generate-chiron-insight-cards tool + datasource LUID resolver

Add a deterministic Chiron insight-cards path to tableau-mcp:
- resolve-datasource-luid: exact case-sensitive contentUrl -> LUID resolution
  (name is not unique; REST contentUrl filter is case-insensitive).
- generate-chiron-insight-cards: resolves a datasource, reads metadata to pick
  measures/time field/dimensions, runs period-over-period detail bundles via a
  shared runChironBundle helper, and shapes governed InsightCard JSON
  (headline, delta, direction, PoPC comparison, series, breakdown).
- Register the tool + scopes; expose contentUrl on datasource types.

Co-authored-by: Cursor <cursoragent@cursor.com>

* @W-23388458: Expand Chiron insight cards to full detail payload + drill-down

Extend generate-chiron-insight-cards from a headline card into the full
governed insight-detail payload and support recursive drill-down:

- InsightCard now carries context, contributors (with % of lift),
  pronounced (most/least), unusual (anomaly), followUps, actions, and
  breakdownDimension; extractors read these from the Pulse detail bundle.
- Add drill params: breakdownDimension (scopes the breakdown to one
  dimension) and filters (member scoping). Response returns the full
  dimension list so drilled cards still propose fresh follow-ups, while
  only the active dimension scopes allowed_dimensions.
- Member-filter contract confirmed live against Pulse: OPERATOR_EQUAL with
  categorical_values [{ string_value }] only, in metric_specification
  (adding '='/bool_value/null_value or using basic_specification 400s).
- Unit tests cover the detail payload, drill params, and the filter shape.

Co-authored-by: Cursor <cursoragent@cursor.com>

* @W-23388458: Rename tool to generate-insight-cards; drop Chiron/Pulse from public name + description; fix tsc types; bump version

* @W-23388458: Address PR review - bounded-context guards, surface bundle failures, validate drill args, fix nondeterministic/aliased fields

* @W-23388458: De-Chiron internal names -> insight* (folder/files/functions/types); no behavior change

* @W-23388458: Address review - indistinguishable not-found/not-allowed + gate before identity lookup; opt-in (disabled-by-default) registration; validate measures/timeField; deterministic series sort

* @W-23388458: Gate insight tools behind INSIGHTS_TOOLS_ENABLED (off by default) + deterministic field selection

---------

Co-authored-by: Cursor <cursoragent@cursor.com>

* @W-23377934: fix extract-refresh-task delete preview existence check + confirm double-audit (#529)

* @W-23377934: fix extract-refresh-task delete preview existence check + confirm double-audit

Two defects in the consolidated delete-content flow:

1. Preview minted a confirmationToken for a non-existent extract-refresh-task.
   The task branch's resolveTarget was a pure echo with no REST read, so a
   well-formed but non-existent taskId passed the UUID check and got a live
   nonce. Unlike the workbook/datasource branches (which 404 early via
   getWorkbook/queryDatasource), the task branch never read the target and
   there is no single-get task endpoint. Fix: list-and-find existence check
   before minting the nonce (preview + confirm), returning a 404 not-found.
   Requires tableau:tasks:read, added to the delete-content scope map.

2. Confirm phase double-logged audit. guardMutation emitted an unconditional
   'allowed' record plus the terminal completed/failed via recordOutcome, so
   every confirmed mutation logged twice (all resourceTypes / all preview->
   confirm tools). Fix: suppress 'allowed' on the confirm phase so a confirm
   emits exactly one terminal record; preview still emits its single 'allowed'.
   Audit lifecycle doc comments updated to the one-record contract.

Tests: 6 new pinning tests (nonexistent/empty-list task -> 404 + no token + no
delete; confirm -> exactly one completed/failed; preview -> single allowed).
Docs: delete-content.md documents the existence check and corrects the REST-API
reference (List, not the non-existent Get, Extract Refresh Tasks).
Version: 3.1.2 -> 3.1.3.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* @W-23377934: fix e2e list-tools to filter INSIGHTS_TOOLS_ENABLED-gated tools

The e2e "should list tools" assertions (default + combined variants) built
expectedToolNames from webToolNames but never filtered the insights tools
(generate-insight-cards, resolve-datasource-luid) added in #502, which are
gated behind INSIGHTS_TOOLS_ENABLED (off by default). The built server omits
them, so the toHaveLength check failed (received 20/26 vs expected 22/28).

This surfaced on this PR because it runs from the main repo (not a fork), so
the fork-guarded E2E CI step actually executes. Added the insights filter to
both variants, mirroring the existing FLOW_TOOLS_ENABLED gate.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* @W-23125362: complete mutation-tool safety layer — audit target enrichment + centralized HITL text + durability contract (#533)

Scope-completes the common mutation-tool safety layer (guard + evidence +
audit already merged). Finishes the in-scope remainder; auth-session tools
(revoke/reset), a durable external sink, and e2e tiers are forked to a
follow-up story.

- AC-3 (Gap B): enrich extract-refresh-task audit target with name/project/
  owner, derived from the underlying datasource (else workbook). New shared
  helper resolveExtractRefreshTaskTarget.ts; best-effort, degrades to id-only
  on Server sites or 403/404 and never blocks/fails the mutation. Applied to
  all four task paths (delete, confirm-delete, update, confirm-update). The
  delete branch reuses its existence-check task list (no extra round-trip).
- AC-4 (Gap D): centralize tool-side HITL text into new hitlText.ts
  (NEXT-STEP preview, confirm-closed, PreviewNotRun recovery). Byte-for-byte
  behavior-preserving; rethreaded delete-content, update-cloud, and the
  guard's PreviewNotRun branch. Kept in tools/_lib to avoid a tools→prompts
  cross-layer import.
- AC-3 (Gap C, doc): document the durability contract — audit records emit as
  structured JSON on the dedicated `audit` logger (bypasses LOG_LEVEL); durable
  retention is the deployment's responsibility (ship the stream to an immutable
  store). Added to auditRecord.ts and delete-content.md.
- AC-6 (Gap E, unit): per-tool assertions that enriched target fields appear in
  audit records on allow and deny; forged-confirm reject and preview→confirm
  happy paths.

Version 3.1.3 → 3.1.4. Full suite green (150 files / 2306 tests); tsc, lint,
build clean.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* @W-23448424: add update-user tool — two-phase HITL site role update (#516)

* feat: add update-user tool — two-phase HITL site role update

- New `update-user` MCP tool with preview/confirm flow using RegistryEvidence
- OAuth scope: `tableau:mcp:users:write` → `tableau:users:update` + `tableau:users:read`
- Admin-gated, UUID-validated userId, enum-constrained siteRole
- Primary use case: downgrade inactive users to Unlicensed for license reclamation

@W-23448424

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: relax update-user response schema to accept partial user

The Tableau PUT /users/:userId endpoint only echoes back changed
fields (e.g. siteRole, name, email) without `id`. Using
`userSchema.partial()` prevents Zodios response validation failure.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* chore: bump version to 3.2.0 for update-user feature

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: address PR review findings — idempotentHint, test coverage, scope phrasing

- Set idempotentHint: false (nonce is single-use; repeated calls reject)
- Add preview safety test: mockUpdateUser.not.toHaveBeenCalled()
- Add partial-response fallback test (API omits siteRole)
- Add missing siteRole/email fallback text test
- Add RegistryEvidence binding-isolation test (role-swap protection)
- Add e2e behavior test (preview, schema validation, bogus token rejection)
- Fix scope phrasing: list both tableau:users:update and :read
- Add comment explaining SupportUser/ServerAdministrator omission

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: gate update-user e2e tests behind UPDATE_USER_E2E_ID

Unlike the sibling tool, update-user's resolveTarget calls
queryUserOnSite which 404s for non-existent user IDs. Gate
preview and bogus-token tests behind the env var so they skip
gracefully when no real user LUID is configured.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: split mutating e2e test behind UPDATE_USER_E2E_DESTRUCTIVE_ID

Mirror sibling pattern (updateCloudExtractRefreshTask) — the preview
and bogus-token tests use UPDATE_USER_E2E_ID, while the actual mutation
uses a separate UPDATE_USER_E2E_DESTRUCTIVE_ID to prevent accidentally
downgrading a real user. All guidance consistently says "disposable user".

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* @W-23336786  ci: bump GitHub Actions to latest major versions (#507)

ci: bump GitHub Actions to latest major versions

  Updates actions/checkout (v3/v4→v7), actions/setup-node (v4→v7),
  actions/upload-artifact (v4→v7), actions/upload-pages-artifact (v3→v5),
  actions/deploy-pages (v4→v5), actions/github-script (v8→v9),
  docker/setup-buildx-action (v3→v4), docker/login-action (v3→v4),
  docker/metadata-action (v5→v6), and docker/build-push-action (v5→v7)
  across all workflows.

* @W-23336786 Add action to automatically label "feature" PRs (#451)

* Ignore .DS_Store

* Add .github/workflows/pr-feature-label.yml, which fires when a PR is
opened against a base branch starting with "feature/" and labels it with
the branch remainder prefixed "feature:" (e.g. base feature/rounded-corners
-> label feature:rounded-corners).

Uses actions/github-script to get-or-create the label (auto-created gray if
missing) then apply it. The on.pull_request.branches: ['feature/**'] filter
scopes the trigger to feature base branches; the job carries issues:write +
pull-requests:write and the standard fork/dependabot guard.

* Adjust based on source or destination branchs as "feature"

* Operate consistently on PR actions

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* fix: pin system time in integration tests to eliminate date-dependent flakiness

* Revert "fix: pin system time in integration tests to eliminate date-dependent flakiness"

This reverts commit 4cb7d5f.

Still want to fix this, but in a separate PR.

---------

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>

* @W-23448422: add user-license-reclamation-inform prompt (#517)

* @W-23448422: add user-license-reclamation-inform prompt

- New MCP prompt that identifies inactive licensed users as reclamation
  candidates by paginating list-users (role + lastLogin filters) and
  cross-referencing activity via query-admin-insights (ts-events)
- Config knobs: LICENSE_RECLAIM_INACTIVE_DAYS, LICENSE_RECLAIM_ROLES
- 13 unit tests covering defaults, arg overrides, env var wiring
- Docs page + intro.md entry

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* chore: bump version to 3.3.0

New prompt feature warrants a minor version bump.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: remove broken markdown link to unreleased apply prompt doc

The Docusaurus docs build fails when a markdown link targets a file
that doesn't exist yet. Replace with inline code reference.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: address PR review — input validation, lookback cap, docs

- Add regex validation on `roles` arg to prevent prompt injection
- Cap TS Events `rangeN` at 90-day lookback max (avoids false
  positives when inactiveDays > 90)
- Add `limit: 10000` to ts-events query to bound result set
- Align inactiveDays regex to 1–3650 (matches env var clamp)
- Document MAX_RESULT_LIMIT caveat in docs workflow description
- Add LICENSE_RECLAIM_INACTIVE_DAYS and LICENSE_RECLAIM_ROLES to
  env-vars.md configuration docs
- Add source reference comment for TS Events field captions

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: correct TS Events field captions and address live-verified findings

BLOCKER fixes (live-verified against prod TS Events datasource):
- `Created At` → `Event Date` (correct DATETIME field on TS Events)
- `Actor User Id` → `Actor User Name` (STRING matching username/email,
  joinable with list-users; Actor User Id is an internal integer)

MEDIUM fixes:
- Add second-pass instruction for null-lastLogin users (never signed
  in — strongest reclamation candidates, previously dropped silently)
- Add ETL lag warning (TS Events lags 24–48h behind live state, making
  the safety net leaky — candidates must be human-verified)
- Strengthen "INFORM-only" framing in recommendation note

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* @W-23093467 @W-22551424: enforce stale-content row cap server-side + harden Admin Insights LUID cache (#553)

Part A (W-23093467): move the stale-content-cleanup safety cap from prompt-text
only to server-side enforcement. Add configurable STALE_CONTENT_MAX_ROWS
(default 100, range 1-10000) and enforce it in the stale-content branch of
query-admin-insights: above the cap the tool returns a structured success with
rows:[], the TRUE totalStaleItems/totalStaleSizeBytes, and a ROW_CAP_EXCEEDED
warning guiding the caller to narrow scope. Read-only inform prompt keeps working
(no throw); the destructive apply flow can no longer receive a huge actionable set
even if the model ignores the prompt guard (which remains as defense-in-depth).

Part B (W-22551424): light hardening of the existing Admin Insights LUID resolver
cache. Add an optional maxSize to ExpiringMap (default unbounded; other callers
untouched) with oldest-inserted eviction that clears the pending timeout, wire a
256-entry bound into the resolver cache, and add hit/miss/resolve debug telemetry
(no siteId/PII). Cache invalidation remains TTL + siteId-keying; an explicit
refresh hook is deferred.

Tests: below/at/above-cap + combined-warning cases, config precedence/validation,
ExpiringMap eviction, resolver eviction. Docs: env-vars, request-overrides,
site-settings, query-admin-insights tool page.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* @W-23336786 Add flexibility to PR title check (docs: prefix and skip-title-check label) (#455)

* @W-23336786 Add flexibility to PR title check (docs: prefix and skip-title-check label)

* Improve case insensitive title matching

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* Avoid race condition around label creation

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* Improve least-privilege for write permissions

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* Exclude dependabot PRs

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* @W-23448423: add user-license-reclamation-apply prompt (HITL-guarded) (#518)

* @W-23448423: add user-license-reclamation-apply prompt

HITL-guarded apply prompt that chains 3A inform output into
update-user downgrades to Unlicensed. Follows the same two-phase
preview→confirm pattern as stale-content and extract-optimization
apply prompts. Defaults to dry run; no mutation without explicit
human approval at the Step 4 gate.

- src/prompts/userLicenseReclamation/apply.ts (prompt impl)
- src/prompts/userLicenseReclamation/apply.test.ts (23 tests incl.
  HITL-refusal adversarial cases)
- src/prompts/index.ts (registration)
- docs/docs/prompts/user-license-reclamation-apply.md
- docs/docs/intro.md (table row)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* @W-23448423: add deterministic VDS query templates for Steps 2-3

Self-review finding: Steps 2 (ts-events) and 3 (site-content) told
the model to call query-admin-insights but didn't provide VDS query
objects with exact field captions. The model would have to guess
Tableau-specific field names, causing runtime failure.

Now provides deterministic JSON query blocks (same pattern as
job-optimization-inform) with exact field captions and filters:
- Step 2: ts-events with Login/Login (Embedded) filter, LASTN date
- Step 3: site-content with Workbook/Datasource item-type filter

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* @W-23448423: align apply prompt with validated inform findings

- Fix TS Events field names (Actor User Name, Event Date — not Actor User ID / Event Created At)
- Use Access events instead of Login (catches embedded/API usage)
- Add all compound roles (SiteAdministratorCreator, SiteAdministratorExplorer) to defaults
- Cap rangeN at 90 days (TC standard lookback limit)
- Add limit: 10000 to prevent unbounded result sets
- Add LICENSE_RECLAIM_INACTIVE_DAYS / LICENSE_RECLAIM_ROLES env var support
- Handle null-lastLogin users (provisioned but never signed in)
- Add ETL lag and lookback cap caveats to Fixed notes
- Bump version to 3.5.0

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* @W-23448423: address review findings — Owner LUID, inactivity rule, docs

MAJOR fixes:
- Replace invalid `Owner LUID` caption with `Owner Email` (join by email)
- Fix Step 2 inactivity rule: "whose lastLogin from Step 1 is null" not
  "absent from Step 1"
- Docs: list all 6 license-consuming roles including compound variants

MINOR fixes:
- Clamp arg-path inactiveDays to max 3650 (matches env-path behavior)
- Docs: reword Step 2 from "last login date" to "activity signals"
- Docs: add LICENSE_RECLAIM_INACTIVE_DAYS / LICENSE_RECLAIM_ROLES config

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: prettier formatting for SITE_CONTENT_FIELDS array

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: add lastLogin pre-filter, truncation warnings, and tighten regex

- Require lastLogin to be null or older than inactiveDays threshold
  before flagging a user as inactive (prevents false positives from
  ETL lag on recently-active users)
- Warn admin when TS Events or Site Content queries hit the 10k row
  limit (silent truncation could cause false-positive downgrades)
- Tighten inactiveDays regex to reject 3651-9999 at schema level
  (previously only clamped at runtime via Math.min)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: prettier formatting for regex line break

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* @W-23518533: Group embed-viz and HITL MCP-App code into feature folders (#556)

* refactor: group embed-viz and HITL MCP-App code into feature folders

Split the flat src/web/apps/src/lib/ directory into feature-scoped folders so
each browser-side MCP-App bundle is self-contained:

- embed/  — Tableau viz embedding for get-view/get-workbook (mcp-app entry,
            handleToolResult, embedTableauViz, loadTableauEmbeddingApi,
            getEmbedTokenToolClient, openInTableauLink) + mcp-app.html
- hitl/   — in-iframe confirm panel for delete/update preview tools
            (hitl-confirm entry, handleConfirmResult, *ConfirmClient renderers)
            + hitl-confirm.html
- shared/ — mcp-app.css, showError, vite-env.d.ts, assets/, and a new
            vizContainer.ts holding TABLEAU_VIZ_CONTAINER_ID (extracted to
            keep shared/ free of a shared->embed dependency)

HTML templates move next to their entry .ts with relative <script src>. Each
Vite build's root is set to its feature folder in build.ts so single-file
output still lands flat as dist/mcp-app.html and dist/hitl-confirm.html;
appConfig.ts and server.web.ts are unchanged. All moves use git mv to preserve
history. 2365 unit tests pass, lint clean, build produces flat dist.

* chore: bump version to 3.4.1

* bump

* remove unused export

---------

Co-authored-by: Alon Siman Tov <asimantov@salesforce.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Akash Rastogi <akashrastogi12@gmail.com>
Co-authored-by: jarhun88 <34752438+jarhun88@users.noreply.github.com>
Co-authored-by: Joey Constantino <15789096+joeconstantino@users.noreply.github.com>
Co-authored-by: Jialin Mao <30403691+showmethecold@users.noreply.github.com>
Co-authored-by: Tony <3278731+tonythebest@users.noreply.github.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Brian Cantoni <bcantoni@salesforce.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: Akash Rastogi <akash.rastogi@salesforce.com>
…571)

* feat(web): slim param on generate-pulse-metric-value-insight-bundle

Add an optional slim boolean to generate-pulse-metric-value-insight-bundle.
When true, strips the large viz (Vega chart-spec) blobs from every insight and
summary result and attaches a metric_context sibling built from the request, so
a text/card UI (e.g. the Tableau Agent viz-canvas) gets a compact payload it can
render from facts/markup alone. Defaults to false -> response is returned
verbatim, including viz, so existing callers are unaffected.

- slimBundle(): non-mutating viz-strip; single seam for future UI-unused fields
- buildMetricContext(): curated flat fields + verbatim input escape hatch
- pulse.ts: extract pulseBundleInputSchema; add metricContextSchema,
  MetricContext, PulseBundleResponseSlim
- 19/19 tool tests green

Dependency for tab-agent-south-ui viz-canvas insight bundles (W-23447550).

* viz-canvas slim: drop the metric_context input echo

Remove the verbatim request-input echo from metric_context (slim response).
Per the tool-use contract, the caller already holds the request it sent -- it
is carried on the tool_use block and correlated to this result by id -- so
echoing input back into the response is redundant payload in a param whose whole
purpose is to stay slim. The UI reads the request off the wire (tool_use),
not from a response field.

metric_context keeps its curated flat fields (name/measure/time_dimension/
breakdown_dimensions). Also revert the pulseBundleInputSchema extraction (it
existed only to type the echo) so the request schema is unchanged.

- metricContextSchema: drop input
- buildMetricContext: drop input
- test: assert metric_context has NO input; 19/19 green

* viz-canvas slim: drop metric_context entirely — slim is viz-strip only

Every field metric_context carried was a pure projection of the request input
(name/measure/time_dimension/breakdown_dimensions), and the caller already holds
that request — it rides on the tool_use block, correlated to the result by id.
So metric_context was a second, redundant path to data the UI already reads off
the wire; it added zero information. Drop it and let slim mean exactly one thing:
strip the viz blobs.

- slimBundle.ts: remove buildMetricContext (now a single viz-strip export)
- pulse.ts: remove metricContextSchema/MetricContext/PulseBundleResponseSlim
  (reverts to base — no longer in the diff)
- tool: constrainSuccessResult back to `slim ? slimBundle(x) : x`; logAndExecute
  typed PulseBundleResponse
- test: drop the two metric_context tests; assert slim never adds the sibling; 17/17 green
* feat(fields): add verbosity=slim to list-available-fields

list-available-fields returns the field catalog twice (a human-readable ASCII
table plus a full per-field metadata array incl. column_ref) — ~68KB for a wide
datasource (155 fields), which overflows the host's inline-output cap and gets
truncated to a preview the caller can't fully re-read.

Add an optional verbosity param:
- 'full' (default): unchanged — table + full metadata + column_ref. Backward
  compatible for authoring callers that place fields on shelves.
- 'slim': a compact JSON array of { caption, role, datatype } with datasource
  hoisted to the top level, no ASCII table, no authoring metadata. Small enough
  to land inline, for reasoning over fields to pick measures/dimensions (e.g.
  generate-insight-cards). Resolve the exact column_ref via resolve-field.

Adds slim-mode tests.

* docs(fields): tighten verbosity param description

* docs(fields): trim slim-branch comment

* fix(fields): group slim list-available-fields by datasource + add contentUrl

Slim previously hoisted fields[0].datasource and dropped per-field datasource,
misattributing every field past the first in multi-datasource workbooks and
erasing the disambiguator for same-caption fields (review on #535).

Slim now always returns { count, datasources: [{ datasource, contentUrl?, fields }] } —
grouped by datasource so the name is carried once per group (small), and correct
for 2+ datasources. contentUrl (from repository-location) is surfaced per published
datasource as the input resolve-datasource-luid needs; omitted for embedded ones.
Single datasource is one group, so callers parse one consistent shape.

* fix(fields): trim slim verbosity description under the per-tool schema budget

The grouped-shape example inlined in the verbosity param description pushed
list-available-fields to 1229 bytes (over the 1200-byte per-tool cap) and the
total schema 18 bytes over 46000. Trim to a concise description; the grouped
shape is self-describing in the response.

* test(fields): cover slim over the live-session path (no workbookFile)

Addresses review: no existing slim test exercised the live-session path.
Adds a verbosity=slim test with a session and no workbookFile, asserting the
grouped slim shape and that the file cache is never read.

* fix(fields): trim verbosity description under the tool-schema budget

Addresses review: keep the desktop tools/list surface under the 46k
auto-deferral cliff (server.desktop.test.ts). Trims the verbosity param text
to the load-bearing facts rather than raising the cap.

* fix: slim mode keeps reference-building data; validate verbosity; pin default parity

Takeover fixes on the verbosity=slim feature: slim output now carries
localName, columnInstanceName, derivation, type, and typePivot — without
them a consumer of slim output cannot construct the compound column
reference the authoring tools require (the tool's whole purpose). Already-
aggregated calc fields covered in slim (usr: + isAggregated). verbosity is
zod-enum validated; absent verbosity is pinned byte-for-byte to full.
Descriptions tightened for the combined-lean budget.

Co-Authored-By: Claude <noreply@anthropic.com>

---------

Co-authored-by: Matt Filbert <mattcfilbert@gmail.com>
Co-authored-by: Claude <noreply@anthropic.com>
…s, dialog refusals) (#572)

* feat: env-gated External-API command-registry guard on the execute path (2.30.0)

execute-tableau-command consults an OPTIONAL runtime registry
(EXTERNAL_API_REGISTRY_DIR, provided by the host; never shipped in this
repo) to kill two live failure classes on the generic invokeCommand route:
wire-name rewrite (167/330 commands have params whose registered wire name
differs from CamelToDashed(local) — unknown keys surface as bare 500s) and
enum validation against serialized value lists (errors teach the legal
values in-band). Registry-known commands with agent_can_invoke=false or
opens_blocking_dialog=true are refused; required params are checked with
context-filled (UPI_Workspace) awareness.

The eight live-observed dialog-popper commands are refused UNCONDITIONALLY
(registry or not) — they hang Desktop's UI thread headlessly and pass every
static safety flag.

Env unset or files unreadable → behavior identical to today (fail-open to
the bundled guards). Tests use synthetic fixtures only.

Co-Authored-By: Claude <noreply@anthropic.com>

* style: lint fixes (prettier + drop unused import after unconditional blocklist hoist)

Co-Authored-By: Claude <noreply@anthropic.com>

* fix: land Lauren's #572 review — drop clipboard false positive, correct change-site guidance

- tabui:copy-zone-to-desktop-clipboard removed from the blocklist (likely
  false positive per the API owner; removable pending re-sweep evidence)
- tabui:workgroup-change-site refusal text corrected: changing sites is a
  fine user action — the refusal is only that the command opens a dialog
  headlessly; guidance now points the user at Desktop's own site switcher

Co-Authored-By: Claude <noreply@anthropic.com>

* refactor: one command-policy table — collapse five scattered special-command maps (#574)

refactor: one command-policy table + gate — collapse five scattered special-command maps

CRASH_PRONE_COMMANDS, LIVE_DIALOG_BLOCKLIST, LIVE_PARAM_OVERRIDES,
KNOWN_LIVE_FAILURE_FIXES, and LIVE_EXTERNAL_API_DIALOG_BLOCKLIST lived in
three files, discovered by separate live sweeps and never reconciled —
tabdoc:sort-nested was simultaneously 'usable with these 9 params' and
'fails regardless, never retry'. One module (commandPolicy.ts) now holds
every special-command disposition with refuse/hint/param-override
semantics preserved and per-entry origin comments; all call sites read
from it. sort-nested is one coherent record: param contract kept for
validation messages, do-not-retry hint authoritative.

Co-authored-by: Claude <noreply@anthropic.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>
* feat: data-first tools — get-summary-data + list-site-datasources (2.31.0)

The agent's first play was a structural read (workbook XML); for data
questions and data-shaped authoring choices that is backwards. Two new
desktop tools on the External Client API typed reads:

- get-summary-data: the summary/logical table behind a worksheet
  (GET /v0/workbook/worksheets/{id}/summaryData). Resolves worksheet
  name→id via the worksheets list (honest ambiguity/not-found errors),
  clamps maxRows (200 default / 1000 cap), optional column filter, and
  reports shape ('N rows x M columns'). The description teaches the
  marks-card mechanic: summary data carries only fields ON the view —
  add others to Detail first, then read.
- list-site-datasources: datasources published to the connected site
  (GET /v0/site/datasources) — the active-sheet → published-LUID bridge
  the Pulse insight flow needs.

Both join the default dynamic-authoring profile. Pre-#60211 Desktop
builds 404 these routes — get-summary-data degrades honestly (named
'endpoint-not-in-this-build' error, no retry loop) with a regression
test on the route-miss detector.

To keep the full-surface tools/list under the 46k deferral cliff while
the (much smaller) dynamic profile keeps rich descriptions, verbose
full-only tool descriptions were tightened (propose-template,
list-templates, delete-worksheet, get-worksheet-xml) — same meaning,
fewer bytes.

Validated: tsc clean, eslint clean, full suite 4878 passed / 1 skipped.

Co-Authored-By: Claude <noreply@anthropic.com>

* feat: first-class wave 2 — inventory, workbook datasources, site workbooks, app info

Four typed reads on the External Client API: get-workbook-inventory
(/v0/workbook — one orienting call) + list-workbook-datasources
(/v0/workbook/datasources — the local half of the LUID bridge) join the
dynamic-authoring profile; list-site-workbooks (/v0/site/workbooks, LUIDs)
+ get-app-info (/v0/app, build/version) in the full set. Client methods,
zod pins, mock routes, honest route-missing handling, tests.

Full-only description prose was trimmed for the 46k cliff; the cliff test
retires in the next commit per owner decision, restoring free prose.

Validated: tsc + eslint clean, full suite 4910 passed / 1 skipped.

Co-Authored-By: Claude <noreply@anthropic.com>

* feat: first-class wave 3 — full schema pins, per-item documents, slice workaround retired

types.ts now pins every schema in the openapi (24). Client/executor/mock
grow dashboard+storyboard lists, per-item document routes, and
document:validate. On the External Client API transport, get-worksheet-xml
/ get-dashboard-xml fetch the TARGET's own document (list→id→
/{id}/document) and list-worksheets / list-dashboards ride their typed
routes — no whole-document fetch, no client-side slicing; the old path
survives only as the fallback for transports without first-class routes.
Old builds that predate the routes get honest endpoint-not-in-this-build
errors.

Validated: tsc + eslint clean, full suite 4937 passed / 1 skipped.

Co-Authored-By: Claude <noreply@anthropic.com>

* feat: first-class wave 4 — server-side validate, prune loop retired (land Lauren's #573 review)

validate-workbook-xml now calls POST /v0/workbook/document:validate on the
External Client API transport and surfaces the server's ValidationResult
(authoritative: schema + semantics, not just well-formed); the local
well-formed check stays as fast pre-flight and as the fallback for other
transports, with the honest old-build error.

The replaceWorkbook + tabdoc:delete-sheet prune loop is DELETED (not kept
as fallback): whole-workbook POST replaces natively per the API owner, and
caller tracing showed the loop only ever ran under externalApiEnabled —
the non-External-API path uses loadUnderlyingMetadataByFilepath and never
touched it. Whole-workbook apply is now a single applyWorkbookText.

Transport-only (client methods, no tool, by design): /v0/site, /v0/health,
/v0/ (ApiRoot), per-item {id} info routes, storyboard documents (a
get-storyboard-xml tool waits for storyboards to become user-facing scope).

Validated: tsc + eslint clean, full suite 4939 passed / 1 skipped.

Co-Authored-By: Claude <noreply@anthropic.com>

* feat: first-class waves 5+6 — every functional route a tool; review fixes

Wave 5 (the API owner's final gap list): 8 tools — get-api-root,
get-health, get-site-info, get-worksheet-info, get-dashboard-info,
list-storyboards, get-storyboard-info, get-storyboard-xml — with the 4
missing client methods. ZERO functional routes without a tool; all 8
full-set-only with terse descriptions, dynamic profile unchanged.

Wave 6 (adjudicated dual-context review): optional session arg on every
first-class read tool (multi-Desktop pinning safety); route-miss
detection unified in one shared helper and applied at every call site
incl. prerequisite lists; contentUrl described honestly (spec does not
declare it — surfaced when the build provides it); name resolution
decodes XML entities on fallback. package-lock synced to 2.31.0.

Validated: tsc + eslint clean, full suite 4966 passed / 1 skipped.
NOTE: combined-lean byte margin is now 18 bytes — the 46k ceiling is
effectively exhausted; next surface growth forces the pending decision.

Co-Authored-By: Claude <noreply@anthropic.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>
…nt hardening) (#575)

Resync main → feature/desktop (23 commits: id_type, MCP-Apps folders, stale-content hardening, prompts)

Second same-day resync bringing main's 23 commits — notably #528's optional
datasource id_type on the insight bundle request (Pulse-relevant) and
#553's Admin Insights LUID-cache hardening. Conflict resolutions repeat
the established pattern: version line stays feature/desktop's (2.31.0,
lock regenerated), publish.yml keeps the desktop-variant build + asserts
composed with main's guarded publish, server.web.ts absorbs main's
changes into the lazy-registration structure.

Validated: tsc clean, full suite 4988 passed / 1 skipped (post lock regen).

Co-authored-by: Claude <noreply@anthropic.com>
#576)

feat: surface invokeCommand output — consume the 0.1.1 result envelope (2.32.0)

Monolith #60596 (apiVersion 0.1.1) serializes a SUCCEEDED command's output
params into Operation.result. The client now carries it end to end:
envelope pin (optional object, scoped to 0.1.1) → normalizeEnvelope →
buildCommandStatus → execute-tableau-command's success payload
{ message, result, warnings } with honest 16KB truncation. FAILED
envelopes surface the real error message plus the tableau-error-code
extension instead of a generic failure; serialize-degradation warnings
render as warning lines. apiVersion <=0.1.0 instances stay silent on
missing result (feature-detected from discovery, never probed). The
'envelope never returns output' write-blind claims are corrected and
scoped to <=0.1.0. Contract-harness drift detection preserved for the
capture that first declares result.

Non-goal (unchanged): the bare-500 unknown-param class — that remains the
command-registry guard's territory.

Validated: tsc + eslint clean, full suite 4994 passed / 1 skipped.

Co-authored-by: Claude <noreply@anthropic.com>
…le, slim deprecated (#577)

refactor: converge on one slim vocabulary — verbosity enum, slim boolean deprecated

Two 'slim' dialects shipped the same week: list-available-fields'
verbosity enum vs the Pulse bundle's boolean slim (different types, same
word). The bundle tool adopts verbosity: 'full'|'slim' with identical
semantics (slim strips the viz Vega blobs); the boolean stays as a
deprecated alias so existing callers keep working — verbosity wins when
both are supplied, parity-tested byte-identical.

Co-authored-by: Claude <noreply@anthropic.com>
…ructional prose (#579)

chore: scrub internal engineering references; retire NotionalSpec readback + prose

Public-repo hygiene (API owner's #576 review + owner directive): all
internal PR/repo references in comments and strings reworded to neutral
public-safe phrasing (apiVersion-scoped, behavior-preserving); the
commands-reference NotionalSpec entries state constraints only — the
spec-authoring instruction is gone (retired path); the
generate-viz-from-notional-spec readback special case and its helper are
removed from execute-tableau-command (the containment guard stays).

Co-authored-by: Claude <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants