Skip to content

Releases: alexey-pelykh/ttctl

v0.1.1

16 Jun 18:57

Choose a tag to compare

Patch release.

  • npmjs.com/ttctl now renders the full project README (previously a short 60-line per-package summary). The ttctl umbrella package ships the complete README; @ttctl/core / @ttctl/cli / @ttctl/mcp keep their focused per-package docs.
  • Release pipeline: restored native pnpm publish (a prior npm publish change was reverted as a misdiagnosis — no effect on published artifacts or provenance).

No code or behavior changes to the CLI or MCP server.

v0.1.1-rc.1

16 Jun 18:32

Choose a tag to compare

v0.1.1-rc.1 Pre-release
Pre-release

Pre-release validating the npm README fix: publish the pre-packed tarballs via npm publish so the registry readme field populates (pnpm publish leaves it blank). Publishes to the npm next dist-tag only; does not affect latest (0.1.0). Not for general use.

v0.1.0

16 Jun 14:48

Choose a tag to compare

First stable release. npm install -g ttctl now resolves to a versioned, provenance-signed build (npm provenance + SLSA attestations + CycloneDX SBOMs).

TTCtl is an unofficial, personal-productivity CLI and MCP server for your own Toptal Talent profile — read-heavy access to the data you already see in the portal, plus the handful of write verbs the platform exposes for self-service.

⚠️ Unofficial — personal use only

TTCtl is an independent project, not affiliated with, endorsed by, or sponsored by Toptal. "Toptal" is used nominatively to describe what the tool interoperates with. It operates only against your own profile, using your own session. Please read the README disclaimer and use policy before installing — the README is the authoritative statement of scope and acceptable use.

What's in it

A CLI and an MCP server over the same core:

  • auth — sign in (1Password reference or literal credentials), capture and persist a session bearer, status, sign out
  • profile — basic info, skills, employment, education, certifications, industries, portfolio, visas, résumé, external links, reviews, photo
  • applications — activity items, interview + availability-request detail, interest-request responses
  • engagements — list, breaks, stats
  • jobs — browse, recommended, match-quality, rate-insight, apply funnel, signals
  • timesheets — list, show, submit, update
  • availability — working + allocated hours
  • contracts, payments (payouts, methods, rate, summary), and surveys

The MCP server exposes the same surface to Claude Desktop / Claude Code / Cursor / Windsurf, with file-upload sandboxing and a stdio-only trust model (see SECURITY.md).

Install

npm install -g ttctl@0.1.0
ttctl auth signin

Or run without installing:

npx ttctl@0.1.0 auth signin

Hardened install (skip lifecycle scripts):

npm install -g --ignore-scripts ttctl@0.1.0

Requires Node.js ≥ 22.19.0.


The incremental development history (v0.1.0-rc.1v0.1.0-rc.18) is in CHANGELOG.md.

v0.1.0-rc.18

16 Jun 14:31

Choose a tag to compare

v0.1.0-rc.18 Pre-release
Pre-release

Pre-release cut from main (2 commits since rc.17).

Security

Changed

  • Portfolio/visas mutation wire shapes verified live (#90)

Full notes: see CHANGELOG.md.

v0.1.0-rc.17

15 Jun 15:38

Choose a tag to compare

v0.1.0-rc.17 Pre-release
Pre-release

Pre-release cut from main (22 commits since rc.16).

Added

  • timesheet update — the missing timesheet CRUD verb (#458)
  • profile show --full rich portal projection (#469)
  • profile education writable skills surface (#633)
  • me actions list — performed-actions audit log (#389)
  • Jobs read surface (wave-2): jobs show-many (#471), recommended (#472), match-quality (#473), rate-insight (#474), dashboard (#479)
  • engagements payments list (#388)
  • payments show-many (#456)
  • payments methods list now surfaces availableMethods (#812)

Changed

  • @ttctl/core transport/ + auth/ module split, ARCH-003 (#230)

Fixed

  • SMS-consent guidance: UPDATE_BASIC_INFO is not server-gated (#540)
  • education add requires the full create field set upfront (#803)

Full notes: see CHANGELOG.md.

v0.1.0-rc.16

14 Jun 14:50

Choose a tag to compare

v0.1.0-rc.16 Pre-release
Pre-release

Added

  • CLI/MCP parity contract test (#151).
    packages/ttctl/src/__tests__/cli-mcp-parity.test.ts (runs in
    pnpm test) is the sibling parity gate to surface-coverage: it walks
    the live Commander tree and a constructed MCP server's tool registry
    (a new listRegisteredMcpToolNames() export from @ttctl/mcp) and
    reports when a CLI leaf ttctl <group> <sub-domain> <verb> and its
    ttctl_<group>_<sub-domain>_<verb> MCP tool drift apart. Runtime
    discovery resolves the template-literal tool registrations a source
    scan cannot; intentional divergences live in .mcp-exempt.yaml or an
    inline // mcp-exempt: comment. Warn-by-default;
    CLI_MCP_PARITY_STRICT=1 fails on drift.
  • Scalar type-consistency lint gate (#782).
    scripts/check-scalar-type-consistency.ts (wired into pnpm lint)
    cross-references hand-authored export interface scalar fields under
    packages/core/src/services/** against the generated codegen named
    types and Zod schemas, flagging a hand-authored field whose primitive
    contradicts the wire scalar — the structural defense against the #275
    mistype class (#275, #779). Field-name match on a single unambiguous
    contradiction; warn-by-default with // scalar-consistency-exempt:
    markers and SCALAR_CONSISTENCY_STRICT=1.

Changed

  • Surface-coverage gate follows sibling-file re-exports (#662). The
    Class A gate parsed only export async function and
    export const ns = {}; it now also follows value re-exports
    (export { name } from "./sibling.js", honoring as aliases and
    ignoring export type), attributing the op to the importing index's
    namespace — so an op implemented in a sibling file (e.g.
    profile.employment.reportingToAutocomplete) is no longer invisible
    to it.
  • README-verbs gate resolves ttctl_* MCP tool-name claims (#765).
    The #762 gate routed every ttctl_* backtick span to its unchecked
    report; those spans now resolve against the registered MCP tool roster
    (EXPECTED_TOOLS, pinned to the live server registry), so a README
    naming a renamed or removed tool is a strict finding rather than a
    silent unchecked row.

Fixed

  • Scalar type corrections: paymentGroupId and time-zone offsets are
    number, not string (#779).
    Two #275-class mistypes where the
    hand-authored TypeScript contracted a string while the SDL, generated
    codegen, and live wire all return a numeric Int. payments
    Payout.paymentGroupId / WirePayment.paymentGroupId retyped
    string | nullnumber | null (live wire returns group ids like
    261280). availabilityAvailabilityTimeZone.utcOffset /
    .stdOffset retyped string | nullnumber | null (live wire
    returns offset seconds, e.g. 3600 for UTC+1), found by the
    accompanying suite-wide scalar-mistype sweep. Runtime rendering is
    unchanged (the wire already sent numbers); the fix aligns the type
    contract so string operations on these fields are no longer silently
    wrong. The Payments wire snapshot was hand-corrected (and
    live-verified against a populated cycle): paymentGroupId from a
    degenerate null capture to nullable<number>, and billingCycle/job
    from a degenerate non-null capture to their true nullable<object> shape.

v0.1.0-rc.15

13 Jun 09:15

Choose a tag to compare

v0.1.0-rc.15 Pre-release
Pre-release

Added

  • README-verbs lint gate (#762). scripts/check-readme-verbs.ts
    (wired into pnpm lint) mechanically diffs the README's capability-verb
    claims against the registered CLI command tree and fails the gate when
    the README advertises a verb the CLI does not ship — a structural
    defense against the #751 drift class.

Changed

  • MCP README tool catalog recounted: 88 → 129 tools (#769).
    packages/mcp/README.md understated the registered MCP surface — it
    claimed 88 tools, omitted the surveys domain from the per-domain
    breakdown, and carried stale per-domain counts. Recounted against the
    canonical tool enumeration: 129 tools across 10 domains, surveys
    added, per-domain counts refreshed and reconciled to the total.
    Docs-only; the README ships in the @ttctl/mcp tarball.
  • Expand npm keywords on the ttctl umbrella package (#771). Broadened
    for registry discoverability ahead of the first stable (toptal,
    freelance, mcp-server, model-context-protocol).
  • CI: Codecov upload moved to a dedicated OIDC coverage job (#761,
    #759).
    The upload now authenticates via OIDC, and CODECOV_TOKEN is
    scoped to the upload step rather than the whole workflow.

Fixed

  • Drop the unshipped timesheet update verb from the README (#751).
    The root README's Timesheets bullet advertised an update verb with no
    UpdateTimesheet invocation in core and no timesheet update CLI
    command; corrected to "list, view, and submit". Restore when #458 lands.
  • Generate coverage at the repository root so the Codecov upload
    delivers (#760).
    Coverage was produced per-package, leaving the upload
    step with nothing to deliver; it is now generated at the root level.

Security

  • Disposition the 9 transitive npm audit advisories (hono ×7,
    ip-address, qs) via a documented allowlist (#770).
    All nine arrive
    through @modelcontextprotocol/sdk's optional HTTP/SSE transport stack
    (hono / express-rate-limit / qs / ip-address) and require an active HTTP
    request handler; ttctl's MCP server is stdio-only, so the vulnerable
    paths never enter its runtime module graph — present-but-unreachable (a
    security-architect review confirmed the reachability claim). They are
    pinned per-GHSA in pnpm-workspace.yaml auditConfig.ignoreGhsas (so
    future advisories still surface), with the full triage and reachability
    proof in ADR-011 and the posture plus re-review trigger in SECURITY.md.
    A reachable fast-uri advisory surfaced during triage was closed with a
    pnpm.overrides bump.

Dependencies

  • Bump undici 8.3.0 → 8.4.1 (#747), prettier 3.8.3 → 3.8.4 (#746),
    turbo 2.9.16 → 2.9.18 (#745), typescript-eslint 8.60.1 → 8.61.0
    (#749), graphql 16.14.0 → 16.14.2 (#748), codecov/codecov-action
    6.0.1 → 7.0.0 (#744).

v0.1.0-rc.14

12 Jun 12:38

Choose a tag to compare

v0.1.0-rc.14 Pre-release
Pre-release

Added

  • applications interview show: surface the client-side contact block
    (clientContactInfo) (#682).
    The captured Android Interview doc
    carries the client's contactFields (Slack id, email, phone, Skype),
    but ttctl's INTERVIEW_QUERY and projection trimmed them. The
    client-side contact — distinct from the interviewer/recruiter-side
    contacts[] — is now selected and projected on InterviewDetail, and
    the CLI renders a Client section after Contacts, omitted unless at
    least one channel is populated. MCP: ttctl_applications_interview_show
    auto-inherits the field (the tool JSON-serializes the full projection).
    Wire-shape disposition: Schema/contract rule triggered (selection
    extension on the hand-authored Interview op); Track 1
    (packages/e2e/src/wire-snapshots/Interview.snapshot.json refreshed —
    the live run captured the field populated on an external interview).
    Validated live (TTCTL_E2E=1) via
    packages/e2e/src/62-applications-interview-show.e2e.test.ts.
  • applications interview show: surface
    contacts[*].topChatConversation (#683).
    The per-contact TopChat
    discovery handle — selected by the captured Android Interview.graphql
    but trimmed from ttctl, the per-contact twin of #682. Each
    InterviewContact now carries topChatConversation (id,
    slackChannelId flattened from the TopChatConversationSlackService
    inline fragment, and uploads[] with id / filename / url), and
    the CLI renders a per-contact TopChat: block under Contacts.
    Discovery handle only — #23 owns the full TopChat surface (messages,
    downloads). Wire-shape disposition: Schema/contract rule triggered
    (the conversation/upload sub-shape is INFERRED from the captured doc;
    the synthesized schema grounds only
    TopChatConversationSlackService.channelId); Track 1 (Interview
    snapshot refreshed). Validated live (TTCTL_E2E=1): the wire returned a
    populated thread (id and slackChannelId confirmed as strings);
    uploads was empty on the live thread, so the upload-item shape stays
    capture-inferred until a populated capture lands.
  • timesheet show: surface TimesheetRecord.hours and persisted
    (#684).
    Both fields are carried by the captured TimesheetRecord
    fragment but were trimmed from ttctl's ops and projection — the last
    member of the #559 op-vs-projection spike batch (siblings #682 / #683).
    Core: TimesheetRecord gains hours: string | null (server-rendered
    hour string, sibling to duration) and persisted: boolean | null
    (save-state flag); both the TimesheetDetails query and the
    SubmitTimesheet mutation select them, so show and submit both
    surface them; MCP auto-inherits. CLI: timesheet show renders the
    server hours verbatim and derives from duration minutes only when
    null — present-hours rows now render the server form (8.0h) instead
    of the computed 8.00h. Wire-shape disposition: Schema/contract rule
    triggered (INFERRED fragment fields — the #275 duration-unit-bug
    class); Track 1 (TimesheetDetails and SubmitTimesheet snapshots
    refreshed, hand-preserving note: nullable<string> against an all-null
    cycle). Validated live (read-only, mobile gateway): hours returned as
    a string and persisted as a boolean across 15 records.
  • Interview read ops: sibling-reach footers (CLI) and tool-description
    hints (MCP) (#694).
    The three interview read ops (interview show /
    interview notes show / interview guide show) trim heavy job-context
    cascades BY-DESIGN (#685) but never said where the full context lives.
    Each pretty render now ends with a discovery footer naming the sibling
    command that carries it (interview show points at
    ttctl applications show <activityItemId>; notes show / guide show
    point at ttctl applications interview show <interviewId>), suppressed
    when the target id is absent; the three MCP tool descriptions gain a
    matching sentence.
    Pretty-only — json / yaml output is byte-unchanged.
    availability-request show is deliberately excluded (it already renders
    the job context inline).
  • applications interview show: inline job.title (#696). Approach B
    of #694 — a user could see an interview but not tell which job it was
    for without a second applications show call. Adds title to the
    Interview op's job selection and renders a null-guarded Title:
    line in the CLI Job section; the MCP payload carries the field
    automatically. Re-evaluated per the #480 BY-DESIGN-to-OVERRIDE protocol:
    a single-field override of the #685 job-cascade trim — the heavy
    jobActivityItemData cascade (roughly 50 fields) stays trimmed and
    reachable via applications show <activityItemId>. Wire-shape
    disposition: Schema/contract rule triggered (selecting title
    directly on interview.job is a hand-authored selection extension);
    Track 1 (Interview.snapshot.json gains job.title: string and
    nothing else — verified surgically, not blind-regenerated). Validated
    live (TTCTL_E2E=1, update and assert modes both 3/3) via e2e file 62;
    job.title returned as a non-null string.

Changed

  • TLS impersonation bumped to chrome_147; the User-Agent now derives
    from the profile (#38).
    node-wreq catalog ^2.2.1^2.4.1 (the
    first release shipping a chrome_147 profile); IMPERSONATE_PROFILE
    chrome_145chrome_147. USER_AGENT now derives its Chrome major
    from IMPERSONATE_PROFILE instead of a second hardcoded literal, so the
    UA and the TLS fingerprint can no longer drift — a profile bump rotates
    the whole identity bundle. The photo-upload multipart path previously
    hardcoded its own Chrome/145.0.0.0 UA (a live cross-layer mismatch);
    it now imports the shared constant. Verified live against the
    Cloudflare-protected talent_profile portal (TTCTL_E2E=1, read-only
    contracts file, 3/3) — no Cf403Error with the chrome_147
    fingerprint. Schema/contract rule NOT triggered (no wire-format
    change; the tracked-path edit is a pure UA-constant refactor).
  • applications availability-request show: the Job section leads with
    Title: (#699).
    Reorders the section to lead with the human-readable
    title (then Job id: / URL: / Client:), matching the Title-first
    order interview show adopted in #696. Pretty-render only — field
    content, alignment, and the json / yaml projection are unchanged.
  • Published tarballs drop compiled test fixtures and orphaned sourcemaps
    (#701).
    Surfaced by the 0.1.0 release-readiness audit (CROSS-1).
    @ttctl/core shipped 20 compiled test-fixture files under
    dist/__tests__/**, and every published package shipped .js.map and
    .d.ts.map files referencing a src/ tree absent from the tarball
    (orphaned — zero debugging value; 224 of @ttctl/cli's 452 files). A
    per-package tsconfig.build.json (build-only; the default
    tsconfig.json stays untouched so type-aware ESLint keeps seeing the
    fixtures) excludes **/__tests__/** and disables sourceMap /
    declarationMap. .d.ts declarations are preserved — consumers keep
    full types. npm pack --dry-run: core 216 → 100 files, cli 452 → 228.
    The sourcemap omission is a recorded, reversible 0.1.0 policy decision.
  • THIRD-PARTY-NOTICES.md ships in all four published packages
    (#705).
    node-wreq prebuilt binaries statically link wreq
    (Apache-2.0) and BoringSSL (Apache-2.0) plus a permissive Rust crate
    graph, and upstream ships no LICENSE / NOTICE files in the binary
    subpackages — as an AGPL redistributor, TTCtl inherits the
    notice-preservation obligation. A root THIRD-PARTY-NOTICES.md records
    the verified licenses and is copied into every published tarball via
    prepack (and listed in files), alongside LICENSE.

Fixed

  • profile.portfolio.add: strip update-only toptalRelated from the
    create wire (#645).
    The MCP add tool advertised toptalRelated and
    add() forwarded it onto the create wire, but PortfolioItemCreateInput
    rejects the field (Field is not defined) — any add supplying it
    failed. A live bogus-id probe settled the asymmetry: create REJECTS the
    field while update ACCEPTS it, and on update the value is
    server-controlled (supplying true reads back false, mirroring
    Employment #402 / #508). add() now strips toptalRelated from the
    create payload; the MCP add tool drops it from its input schema;
    update() keeps it with a server-controlled doc note. The follow-up
    secondary-field audit (#693) probed the remaining optional create fields
    live and settled them as ACCEPTED (highlight, accomplishment,
    clientOrCompanyName, websiteUrl) — toptalRelated stays the only
    rejected optional create field. (#693's interim highlight strip was
    refuted by the live probe and reverted within this release window — no
    net behavior change for highlight.) Wire-shape disposition:
    Schema/contract rule triggered; Track 1 (createPortfolioItem
    snapshot unchanged). Validated live (TTCTL_E2E=1; 12/12 in the final
    #693 run of packages/e2e/src/36-profile-portfolio.e2e.test.ts,
    re-confirming the toptalRelated rejection and update round-trip).
  • node-wreq native-module load failure surfaces an actionable typed
    error, and the supported-platform matrix is documented (#708).
    On
    platforms where node-wreq ships no prebuilt binary (linux-arm64-musl,
    win32-arm64), npm i -g ttctl succeeds — the binaries are optional
    dependencies — and the FIRST Cloudflare-protected (talent-profile)
    call threw a raw Failed to load native module error while
    mobile-gateway calls kept working. A new impersonatedFetch wrapper
    translates the load failure into NativeModuleUnavailableError
    (TtctlError subclass, code NATIVE_MODULE_UNAVAILABLE) naming the
    live platform-arch pair, the supported set, and the two known gaps; al...
Read more

v0.1.0-rc.13

29 May 14:02

Choose a tag to compare

v0.1.0-rc.13 Pre-release
Pre-release

Added

  • surveys: new top-level domain for answering pending Toptal surveys
    across core / cli / mcp (#671).
    A talent can now list, answer,
    and leave free-text feedback on pending surveys (e.g. INTERVIEW_ENDED
    post-interview feedback, NPS, ENGAGEMENT_ENDED) without switching to
    the Toptal portal. All three ops are hand-authored against the
    mobile-gateway surface and route through Track 1 wire-shape snapshots
    (each op is in the codegen-exclusion list, so no generated Zod schema
    exists). The two write ops are consent-gated behind a new ADR-009
    survey-submission consent domain (--consent-survey-submission /
    surveySubmissionConsentIssued, the 5th domain), since both are
    irreversible (there is no un-answer wire op) and route content to a third
    party.
    • surveys.list: read-only PendingSurveys query (#672). Surfaces
      each pending survey's id, kind, title, isMandatory,
      alreadyAnswered, and questions[] (each with id, label, note,
      inputType, and selectable answers[]) — everything an answer flow
      consumes. Core: surveys.list(token). CLI: ttctl surveys list [-o pretty|json|yaml]. MCP: ttctl_surveys_list (read-only, dryRun-capable).
      Wire-shape disposition: Schema/contract rule triggered (new
      hand-authored op; Survey.kind and SurveyQuestion.note are
      Unknown-typed in the synthesized SDL); Track 1 (committed
      packages/e2e/src/wire-snapshots/PendingSurveys.snapshot.json, captured
      from real wire data). Validated live (TTCTL_E2E=1, two runs) via
      packages/e2e/src/88-surveys-list.e2e.test.ts; the live shape matched
      the projected Survey[] contract.
    • surveys.submit: answer a pending survey via SubmitSurvey
      (#673).
      Resolves kind and per-question answer-option ids from the
      pending list, so the caller supplies only <surveyId> and
      <questionId>=<value> pairs. Core: surveys.submit(token, fields, consent, options) (consent gate, then resolve kind and option ids from
      PendingSurveys, then the mutation, mapping USER_ERROR on errors[]
      or success:false). CLI: ttctl surveys submit <surveyId> --answer <qid>=<value> (repeatable) --consent-survey-submission. MCP:
      ttctl_surveys_submit (destructiveHint, consent literal, zero-network
      dryRun preview). Wire-shape disposition: Schema/contract rule
      triggered (hand-rolling SurveyAnswerInput is the inference act);
      Track 1 (committed SubmitSurvey.snapshot.json). Validated via the
      always-on safe paths in packages/e2e/src/89-surveys-submit.e2e.test.ts
      (consent-missing refusal and NOT_FOUND resolution, both exercising
      live bearer auth and the gateway PendingSurveys read) plus a live
      round-trip (2026-05-29) that submitted a real INTERVIEW_ENDED survey
      and confirmed it dropped out of pendingSurveys; the gated DESTRUCTIVE
      positive path (TTCTL_E2E_SUBMIT_SURVEY) automates the real submit for
      opt-in operators.
    • surveys.feedback: free-text feedback via AddSurveyFeedback
      (#674).
      Mirrors submit, and accepts an explicit --kind to reach a
      non-pending survey (e.g. already-answered — the drained-account escape
      hatch). Reuses the survey-submission consent domain (no consent or ADR
      change). Core: surveys.addFeedback(...) sends the portal-shape { kind, surveyId, feedback } over the mobile gateway. CLI: ttctl surveys feedback <surveyId> --text <text>. MCP: ttctl_surveys_feedback
      (destructiveHint, consent literal, dryRun preview). Wire-shape
      disposition: Schema/contract rule triggered; Track 1 (committed
      AddSurveyFeedback.snapshot.json). Satisfied by a capture-based
      disposition
      rather than a fresh live round-trip — routing irreversible
      third-party feedback is the exact harm the consent gate guards, and the
      test account had no pending surveys. The { kind, surveyId, feedback }
      wire shape is established from the portal AddSurveyFeedback capture
      (proves kind exists on the input type), the mobile AddFeedbackToSurvey
      capture (omits kind, proving it optional on the shared input type), and
      the #673 live transcript (the sibling surveys.* op accepts the
      kind-bearing portal shape on the gateway). The always-on safe paths in
      packages/e2e/src/90-surveys-feedback.e2e.test.ts exercise the live wire
      now; the gated positive path (TTCTL_E2E_ADD_SURVEY_FEEDBACK) refreshes
      the snapshot on first natural survey availability.
  • profile.employment.reportingToAutocomplete: server-vetted autocomplete
    for Employment.reportingTo (#468).
    Read-only wrapper over the
    talent-profile GET_REPORTING_TO_AUTOCOMPLETE query — given a name prefix,
    returns the suggestions Toptal will accept for the reportingTo field. A
    min-length prefix gate (whitespace-trimmed) fires BEFORE any profile-id
    resolution or wire call. Core:
    profile.employment.reportingToAutocomplete(token, prefix, options?). CLI:
    ttctl profile employment reporting-to-autocomplete <prefix> [--limit N].
    MCP: ttctl_profile_employment_reporting_to_autocomplete. Wire-shape
    disposition: Schema/contract rule triggered (new hand-authored op under
    packages/core/src/services/profile/employment/**, in
    TALENT_PROFILE_KNOWN_UNTRUSTED_OPS); Track 1. Validated live
    (TTCTL_E2E=1, 2/2) via
    packages/e2e/src/87-profile-employment-reporting-to-autocomplete.e2e.test.ts
    (HTTP 200, no GraphQL errors, parses correctly); the wire-shape snapshot is
    not yet captured — the test account returned empty ([]) for every
    candidate prefix (the account-feature-gated family), so
    assertWireShapeStable auto-captures on the first run that yields a
    suggestion.

Fixed

  • profile.skills.add-connection: accept base64-encoded Relay node ids
    (#646).
    Regression from rc.12's connectionType trim (#626): the
    client-side inferConnectionTypeFromId cross-check demanded the decoded
    V1-{Type}-NNN form, but the canonical wire shape returned by every
    *.list tool (and sent by the SPA) is the encoded base64 form
    (VjEtRW1wbG95bWVudC0xMjM0NQ==). Ids piped from skills.list /
    employment.list / education.list / certifications.list /
    portfolio.list were rejected client-side with VALIDATION_ERROR before
    the wire call, so no working connectionId was reachable end-to-end. Fix
    (Approach B, decode-then-fall-back-to-raw): a new decodeRelayNodeId
    helper base64-decodes behind a printable-ASCII gate (encoded ids decode to
    the Relay-shaped string; raw ids decode to non-printable noise and fall
    through to the raw path), so both the encoded canonical form and the
    decoded back-compat form cross-check correctly. The wire payload still
    ships the caller's original input verbatim — no transformation. Sibling
    description sweep across the add-connection and remove-connection CLI
    help and MCP tool descriptions clarifies that *.list tools return the
    encoded form. Wire-shape disposition: Schema/contract rule triggered
    (touches packages/core/src/services/profile/skills/**), but this is a
    client-side validator widening — the AddProfileSkillSetConnectionInput { skillSetId, connectionId } wire shape is unchanged; Track 1
    (addProfileSkillSetConnection snapshot capture remains the pre-existing
    deferred gap from #462 / #626).

v0.1.0-rc.12

26 May 20:32

Choose a tag to compare

v0.1.0-rc.12 Pre-release
Pre-release

Added

  • profile.employment.skills.add / profile.employment.skills.remove:
    per-skill additive wrappers over the full-replace UpdateEmployment
    (#614).
    Bulk profile uplift across many employments (the maintainer
    carries 13 rows, the largest with 100+ skills) previously forced callers
    to re-implement the read-merge-write bookkeeping ttctl already performs
    internally — one merge script per additive op. Both leaves wrap
    update()'s buildUpdateEmploymentInput merge path: read the current
    row via show(), compute the merged (dedupe by id, preserve current
    order) or filtered skills array, then fire one UpdateEmployment
    mutation per row. Discriminated outcome (updated | noop | preview):
    add returns noop when every supplied id is already linked (no wire
    fire); remove returns noop when no supplied id matches the row AND
    refuses with VALIDATION_ERROR when the filtered set would be empty
    (Toptal server rejects skills: [], naming profile.employment.remove
    as the row-level alternative); caller-supplied duplicates dedupe against
    each other and against current state. Core: profile.employment.skills
    namespace exporting add(token, employmentId, skillIds, options) and
    remove(...). CLI: ttctl profile employment skills {add,remove} <id> --skill-id <id> (repeatable). MCP: ttctl_profile_employment_skills_{add,remove}
    using the existing DRY_RUN_EMPLOYMENT_MERGE_PLACEHOLDER for zero-wire
    preview. Wire-shape disposition: Schema/contract rule triggered
    (path packages/core/src/services/profile/employment/**) but introduces
    no new GraphQL ops — both wrappers route through the existing
    UpdateEmployment mutation; Track 1 (existing snapshot)
    UpdateEmployment snapshot owned by
    46-profile-employment-update-merge.e2e.test.ts. Validated live
    (TTCTL_E2E=1) via packages/e2e/src/79-profile-employment-skills.e2e.test.ts
    (add → idempotent re-add → remove → idempotent re-remove →
    refusal-on-empty).
  • profile.skills.remove-connection: per-edge unlink of one
    ProfileSkillSet → entity link via the removeProfileSkillSetConnection
    mutation (#463).
    Sibling to profile.skills.add-connection (#462 /
    rc.11); removes one connection without cascading to the whole skill-set.
    Wire input is CAPTURED
    (research/captures/web/inputs/RemoveProfileSkillSetConnectionInput.json):
    { skillSetId, connectionId } — two fields, no connectionType (the
    server discriminates the target from the Relay node id's base64 type
    prefix; this refutes the prior Pattern-6 inference in
    research/notes/10). Same ADR-009 profile-capability consent domain
    (profileCapabilityConsentIssued: true). Core:
    profile.skills.removeConnection(token, fields, consent, options)
    returns { skillSetId, connectionsCount, connectionIds, notice } with
    the just-unlinked id absent from connectionIds; consent gate
    (CONSENT_REQUIRED fires BEFORE dry-run); USER_ERROR mapping for
    success: false / errors[]; UNKNOWN for null payload;
    AuthRevokedError / Cf403Error propagated. CLI: ttctl profile skills remove-connection --skill-set-id <id> --connection-id <id> --consent-profile-capability (no --connection-type flag — locked at the
    unit level by expect(flags).not.toContain('--connection-type') to
    prevent regression to the 3-field wire shape). MCP:
    ttctl_profile_skills_remove_connection with destructiveHint: true
    and profileCapabilityConsentIssued: z.literal(true). Wire-shape
    disposition: Schema/contract rule triggered (new GraphQL op
    removeProfileSkillSetConnection under
    packages/core/src/services/profile/**, in
    TALENT_PROFILE_KNOWN_UNTRUSTED_OPS); Track 1 — snapshot file
    packages/e2e/src/wire-snapshots/removeProfileSkillSetConnection.snapshot.json
    is intentionally absent at PR-merge time and captured operator-driven
    via TTCTL_E2E_REMOVE_SKILL_CONNECTION=…:… TTCTL_UPDATE_WIRE_SNAPSHOTS=1
    post-merge (mirrors sibling #462). Validated live (TTCTL_E2E=1) via
    packages/e2e/src/80-profile-skills-remove-connection.e2e.test.ts
    (always-on dry-run + consent-missing paths; gated DESTRUCTIVE positive
    path requires a populated skill-set with a linked target).

Changed

  • profile.skills.add-connection: trim wire-extra connectionType and
    add a Relay-prefix cross-check (#626).
    The capture
    research/captures/web/inputs/AddProfileSkillSetConnectionInput.json
    sends only { skillSetId, connectionId } (two fields) — the server
    discriminates the target from the Relay node id's base64 type segment.
    ttctl's previous send shape (three fields including connectionType)
    was Pattern-6 inferred at #462; this PR aligns the wire to the captured
    shape and keeps --connection-type at the CLI / MCP surface as a
    client-side UX guard. New private inferConnectionTypeFromId helper +
    RELAY_PREFIX_TO_CONNECTION_TYPE map cross-check the declared
    connectionType against the connectionId Relay prefix; both an
    unrecognized prefix and a prefix-vs-declared-type mismatch now throw
    VALIDATION_ERROR BEFORE any wire call. Wire-shape disposition:
    Schema/contract rule triggered (existing op, wire input shape
    modified; touches packages/core/src/services/profile/**); Track 1
    (addProfileSkillSetConnection in TALENT_PROFILE_KNOWN_UNTRUSTED_OPS;
    the response snapshot at
    packages/e2e/src/wire-snapshots/addProfileSkillSetConnection.snapshot.json
    is unchanged, and the request-side trim is asserted at the unit-test
    level via .toEqual on the dry-run preview's variables.input).
    BC: callers passing an unrecognized Relay prefix or a
    prefix-vs-declared-type mismatch now surface VALIDATION_ERROR at the
    service boundary instead of a GRAPHQL_ERROR from the live wire —
    same end-state (operation refused), better diagnostics. Suite-wide
    sweep dropped stale "Pattern-6 / INFERRED wire shape" claims from the
    CLI --help, service / tool JSDoc, the 78-profile-skills-add-connection.e2e.test.ts
    header, and the addProfileSkillSetConnection row rationale in
    docs/wire-validation-routing.md.

Fixed

  • profile.specializations.show exposes operations.apply.callable as
    a string, not a boolean (#637).
    The synthesized schema declares
    Operation.callable: String! (empirical value "ENABLED") and the
    codegen already types every other site correctly; only the
    specializations service mistyped it as boolean and projected with
    ?? false — the wire was always returning a string ("ENABLED"), and
    TTCTL_E2E=1 pnpm test:e2e src/66-profile-specializations-show.e2e.test.ts
    surfaced the drift via typeof apply["callable"] returning "string"
    not "boolean". Scope: SpecializationApplyOperation.callable
    retyped string; projection defaults to "" on a missing wire field;
    pretty / table formatters drop the dead .toString() calls; CLI text
    formatter renders (unset) for the empty default; MCP tool docs
    corrected. BC: the JSON / YAML output for
    ttctl profile specializations show now emits a string value (e.g.
    "ENABLED") instead of a boolean — jq / yq consumers comparing
    with == or === need to compare against the string. Same posture
    for callers of profile.specializations.show() programmatically.
  • profile.education.update no longer nulls omitted writable fields under
    the full-replacement contract, and add/update map ttctl-surface
    institution to wire title (the only school-name slot on
    EducationInput) (#612).
    Same posture as UpdateCertification #605
    and UpdateBasicInfo #604: UpdateEducation treats EducationInput as
    a full replacement — pre-fix, calling ttctl profile education update <id> --highlight true would null every other field server-side. Pre-fix,
    add() and update() also sent education.institution: <value> to a
    wire input that has no institution slot (capture
    research/captures/web/inputs/UpdateEducationInput.json) — the live API
    rejected with GRAPHQL_ERROR, blocking BOTH adding and updating
    education rows from ttctl. Scope: core update() now reads the
    current row via show() then merges through the exported
    buildUpdateEducationInput(current, fields) helper (mirror of
    buildUpdateCertificationInput); add() builds the wire input via
    toEducationWireInput(fields) and defaults skills: [] (the wire
    requires non-null, same .blank? posture as the cert sibling).
    DRY_RUN_EDUCATION_FIELD_PLACEHOLDER is exported for the MCP layer;
    the MCP _update dry-run preview now surfaces the placeholder for
    every unconditional-echo field. BC: removed the --title flag from
    ttctl profile education add/update and the title input on the
    MCP ttctl_profile_education_add/_update tools — the wire title
    slot is owned by institution (school name), and the read-side
    Education.title is server-populated, not user-controlled. Any caller
    passing --title was previously overwriting the school name; no
    preservation path exists because the previous semantics were
    data-corrupting.
  • profile.skills experience is years on the wire, not months (#627).
    The MCP ttctl_profile_skills_update.experience docstring claimed
    "months"; the CLI --experience flag, parseExperience parser,
    validation messages, and pretty-print rendering all carried the same
    wrong-unit assumption (with parseExperience ACTIVELY multiplying Ny
    inputs by 12). The wire capture at
    research/captures/web/inputs/UpdateProfileSkillSetExperienceInput.json
    annotates Int (years; 1-20+), and several talents' public profiles
    rendered nonsense values (e.g., "60 years" for a skill the caller
    intended as 5 years). Scope: corrected every docstring, validation
    message, help text, parser, format helper, service jsdoc, test fixture,
    and snapshot in the skills sub-domain. parseExperience ...
Read more