Releases: alexey-pelykh/ttctl
v0.1.1
Patch release.
- npmjs.com/ttctl now renders the full project README (previously a short 60-line per-package summary). The
ttctlumbrella package ships the complete README;@ttctl/core/@ttctl/cli/@ttctl/mcpkeep their focused per-package docs. - Release pipeline: restored native
pnpm publish(a priornpm publishchange 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
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
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 signinOr run without installing:
npx ttctl@0.1.0 auth signinHardened install (skip lifecycle scripts):
npm install -g --ignore-scripts ttctl@0.1.0Requires Node.js ≥ 22.19.0.
The incremental development history (v0.1.0-rc.1 … v0.1.0-rc.18) is in CHANGELOG.md.
v0.1.0-rc.18
Pre-release cut from main (2 commits since rc.17).
Security
- Dev-toolchain dep patches:
vite≥8.0.16,ws≥8.21.0 past HIGH advisories (GHSA-fx2h-pf6j-xcff, GHSA-96hv-2xvq-fx4p); clears the release-time audit gate (#819)
Changed
- Portfolio/visas mutation wire shapes verified live (#90)
Full notes: see CHANGELOG.md.
v0.1.0-rc.17
Pre-release cut from main (22 commits since rc.16).
Added
timesheet update— the missing timesheet CRUD verb (#458)profile show --fullrich portal projection (#469)profile educationwritable 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 listnow surfacesavailableMethods(#812)
Changed
@ttctl/coretransport/+auth/module split, ARCH-003 (#230)
Fixed
- SMS-consent guidance:
UPDATE_BASIC_INFOis not server-gated (#540) education addrequires the full create field set upfront (#803)
Full notes: see CHANGELOG.md.
v0.1.0-rc.16
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 newlistRegisteredMcpToolNames()export from@ttctl/mcp) and
reports when a CLI leafttctl <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.yamlor an
inline// mcp-exempt:comment. Warn-by-default;
CLI_MCP_PARITY_STRICT=1fails on drift. - Scalar type-consistency lint gate (#782).
scripts/check-scalar-type-consistency.ts(wired intopnpm lint)
cross-references hand-authoredexport interfacescalar 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 andSCALAR_CONSISTENCY_STRICT=1.
Changed
- Surface-coverage gate follows sibling-file re-exports (#662). The
Class A gate parsed onlyexport async functionand
export const ns = {}; it now also follows value re-exports
(export { name } from "./sibling.js", honoringasaliases and
ignoringexport 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 everyttctl_*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:
paymentGroupIdand time-zone offsets are
number, notstring(#779). Two#275-class mistypes where the
hand-authored TypeScript contracted astringwhile the SDL, generated
codegen, and live wire all return a numericInt.payments—
Payout.paymentGroupId/WirePayment.paymentGroupIdretyped
string | null→number | null(live wire returns group ids like
261280).availability—AvailabilityTimeZone.utcOffset/
.stdOffsetretypedstring | null→number | null(live wire
returns offset seconds, e.g.3600for 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. ThePaymentswire snapshot was hand-corrected (and
live-verified against a populated cycle):paymentGroupIdfrom a
degeneratenullcapture tonullable<number>, andbillingCycle/job
from a degenerate non-null capture to their truenullable<object>shape.
v0.1.0-rc.15
Added
- README-verbs lint gate (#762).
scripts/check-readme-verbs.ts
(wired intopnpm 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.mdunderstated the registered MCP surface — it
claimed 88 tools, omitted thesurveysdomain 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/mcptarball. - Expand npm keywords on the
ttctlumbrella 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, andCODECOV_TOKENis
scoped to the upload step rather than the whole workflow.
Fixed
- Drop the unshipped
timesheet updateverb from the README (#751).
The root README's Timesheets bullet advertised anupdateverb with no
UpdateTimesheetinvocation in core and notimesheet updateCLI
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 auditadvisories (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-architectreview confirmed the reachability claim). They are
pinned per-GHSA inpnpm-workspace.yamlauditConfig.ignoreGhsas(so
future advisories still surface), with the full triage and reachability
proof in ADR-011 and the posture plus re-review trigger inSECURITY.md.
A reachablefast-uriadvisory surfaced during triage was closed with a
pnpm.overridesbump.
Dependencies
v0.1.0-rc.14
Added
applications interview show: surface the client-side contact block
(clientContactInfo) (#682). The captured AndroidInterviewdoc
carries the client'scontactFields(Slack id, email, phone, Skype),
but ttctl'sINTERVIEW_QUERYand projection trimmed them. The
client-side contact — distinct from the interviewer/recruiter-side
contacts[]— is now selected and projected onInterviewDetail, and
the CLI renders aClientsection afterContacts, 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-authoredInterviewop); Track 1
(packages/e2e/src/wire-snapshots/Interview.snapshot.jsonrefreshed —
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 AndroidInterview.graphql
but trimmed from ttctl, the per-contact twin of #682. Each
InterviewContactnow carriestopChatConversation(id,
slackChannelIdflattened from theTopChatConversationSlackService
inline fragment, anduploads[]withid/filename/url), and
the CLI renders a per-contactTopChat:block underContacts.
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 (idandslackChannelIdconfirmed as strings);
uploadswas empty on the live thread, so the upload-item shape stays
capture-inferred until a populated capture lands.timesheet show: surfaceTimesheetRecord.hoursandpersisted
(#684). Both fields are carried by the capturedTimesheetRecord
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:TimesheetRecordgainshours: string | null(server-rendered
hour string, sibling toduration) andpersisted: boolean | null
(save-state flag); both theTimesheetDetailsquery and the
SubmitTimesheetmutation select them, soshowandsubmitboth
surface them; MCP auto-inherits. CLI:timesheet showrenders the
serverhoursverbatim and derives fromdurationminutes only when
null — present-hours rows now render the server form (8.0h) instead
of the computed8.00h. Wire-shape disposition: Schema/contract rule
triggered (INFERRED fragment fields — the #275 duration-unit-bug
class); Track 1 (TimesheetDetailsandSubmitTimesheetsnapshots
refreshed, hand-preservingnote: nullable<string>against an all-null
cycle). Validated live (read-only, mobile gateway):hoursreturned as
a string andpersistedas 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 showpoints at
ttctl applications show <activityItemId>;notes show/guide show
point atttctl applications interview show <interviewId>), suppressed
when the target id is absent; the three MCP tool descriptions gain a
matching sentence.
Pretty-only —json/yamloutput is byte-unchanged.
availability-request showis deliberately excluded (it already renders
the job context inline). applications interview show: inlinejob.title(#696). Approach B
of #694 — a user could see an interview but not tell which job it was
for without a secondapplications showcall. Addstitleto the
Interviewop'sjobselection and renders a null-guardedTitle:
line in the CLIJobsection; 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
jobActivityItemDatacascade (roughly 50 fields) stays trimmed and
reachable viaapplications show <activityItemId>. Wire-shape
disposition: Schema/contract rule triggered (selectingtitle
directly oninterview.jobis a hand-authored selection extension);
Track 1 (Interview.snapshot.jsongainsjob.title: stringand
nothing else — verified surgically, not blind-regenerated). Validated
live (TTCTL_E2E=1, update and assert modes both 3/3) via e2e file 62;
job.titlereturned as a non-null string.
Changed
- TLS impersonation bumped to
chrome_147; the User-Agent now derives
from the profile (#38).node-wreqcatalog^2.2.1→^2.4.1(the
first release shipping achrome_147profile);IMPERSONATE_PROFILE
chrome_145→chrome_147.USER_AGENTnow derives its Chrome major
fromIMPERSONATE_PROFILEinstead 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 ownChrome/145.0.0.0UA (a live cross-layer mismatch);
it now imports the shared constant. Verified live against the
Cloudflare-protectedtalent_profileportal (TTCTL_E2E=1, read-only
contracts file, 3/3) — noCf403Errorwith thechrome_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: theJobsection leads with
Title:(#699). Reorders the section to lead with the human-readable
title (thenJob id:/URL:/Client:), matching the Title-first
orderinterview showadopted in #696. Pretty-render only — field
content, alignment, and thejson/yamlprojection are unchanged.- Published tarballs drop compiled test fixtures and orphaned sourcemaps
(#701). Surfaced by the 0.1.0 release-readiness audit (CROSS-1).
@ttctl/coreshipped 20 compiled test-fixture files under
dist/__tests__/**, and every published package shipped.js.mapand
.d.ts.mapfiles referencing asrc/tree absent from the tarball
(orphaned — zero debugging value; 224 of@ttctl/cli's 452 files). A
per-packagetsconfig.build.json(build-only; the default
tsconfig.jsonstays untouched so type-aware ESLint keeps seeing the
fixtures) excludes**/__tests__/**and disablessourceMap/
declarationMap..d.tsdeclarations 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.mdships in all four published packages
(#705).node-wreqprebuilt binaries statically linkwreq
(Apache-2.0) and BoringSSL (Apache-2.0) plus a permissive Rust crate
graph, and upstream ships noLICENSE/NOTICEfiles in the binary
subpackages — as an AGPL redistributor, TTCtl inherits the
notice-preservation obligation. A rootTHIRD-PARTY-NOTICES.mdrecords
the verified licenses and is copied into every published tarball via
prepack(and listed infiles), alongsideLICENSE.
Fixed
profile.portfolio.add: strip update-onlytoptalRelatedfrom the
create wire (#645). The MCP add tool advertisedtoptalRelatedand
add()forwarded it onto the create wire, butPortfolioItemCreateInput
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 (supplyingtruereads backfalse, mirroring
Employment #402 / #508).add()now stripstoptalRelatedfrom 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) —toptalRelatedstays the only
rejected optional create field. (#693's interimhighlightstrip was
refuted by the live probe and reverted within this release window — no
net behavior change forhighlight.) Wire-shape disposition:
Schema/contract rule triggered; Track 1 (createPortfolioItem
snapshot unchanged). Validated live (TTCTL_E2E=1; 12/12 in the final
#693 run ofpackages/e2e/src/36-profile-portfolio.e2e.test.ts,
re-confirming thetoptalRelatedrejection and update round-trip).node-wreqnative-module load failure surfaces an actionable typed
error, and the supported-platform matrix is documented (#708). On
platforms wherenode-wreqships no prebuilt binary (linux-arm64-musl,
win32-arm64),npm i -g ttctlsucceeds — the binaries are optional
dependencies — and the FIRST Cloudflare-protected (talent-profile)
call threw a rawFailed to load native moduleerror while
mobile-gateway calls kept working. A newimpersonatedFetchwrapper
translates the load failure intoNativeModuleUnavailableError
(TtctlErrorsubclass, codeNATIVE_MODULE_UNAVAILABLE) naming the
live platform-arch pair, the supported set, and the two known gaps; al...
v0.1.0-rc.13
Added
surveys: new top-level domain for answering pending Toptal surveys
acrosscore/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-submissionconsent 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-onlyPendingSurveysquery (#672). Surfaces
each pending survey'sid,kind,title,isMandatory,
alreadyAnswered, andquestions[](each withid,label,note,
inputType, and selectableanswers[]) — 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.kindandSurveyQuestion.noteare
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 projectedSurvey[]contract.surveys.submit: answer a pending survey viaSubmitSurvey
(#673). Resolveskindand 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, mappingUSER_ERRORonerrors[]
orsuccess:false). CLI:ttctl surveys submit <surveyId> --answer <qid>=<value>(repeatable)--consent-survey-submission. MCP:
ttctl_surveys_submit(destructiveHint, consent literal, zero-network
dryRunpreview). Wire-shape disposition: Schema/contract rule
triggered (hand-rollingSurveyAnswerInputis the inference act);
Track 1 (committedSubmitSurvey.snapshot.json). Validated via the
always-on safe paths inpackages/e2e/src/89-surveys-submit.e2e.test.ts
(consent-missing refusal andNOT_FOUNDresolution, both exercising
live bearer auth and the gatewayPendingSurveysread) plus a live
round-trip (2026-05-29) that submitted a realINTERVIEW_ENDEDsurvey
and confirmed it dropped out ofpendingSurveys; the gated DESTRUCTIVE
positive path (TTCTL_E2E_SUBMIT_SURVEY) automates the real submit for
opt-in operators.surveys.feedback: free-text feedback viaAddSurveyFeedback
(#674). Mirrorssubmit, and accepts an explicit--kindto reach a
non-pending survey (e.g. already-answered — the drained-account escape
hatch). Reuses thesurvey-submissionconsent 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,dryRunpreview). 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 portalAddSurveyFeedbackcapture
(proveskindexists on the input type), the mobileAddFeedbackToSurvey
capture (omitskind, proving it optional on the shared input type), and
the #673 live transcript (the siblingsurveys.*op accepts the
kind-bearing portal shape on the gateway). The always-on safe paths in
packages/e2e/src/90-surveys-feedback.e2e.test.tsexercise 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
forEmployment.reportingTo(#468). Read-only wrapper over the
talent-profileGET_REPORTING_TO_AUTOCOMPLETEquery — given a name prefix,
returns the suggestions Toptal will accept for thereportingTofield. 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
assertWireShapeStableauto-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'sconnectionTypetrim (#626): the
client-sideinferConnectionTypeFromIdcross-check demanded the decoded
V1-{Type}-NNNform, but the canonical wire shape returned by every
*.listtool (and sent by the SPA) is the encoded base64 form
(VjEtRW1wbG95bWVudC0xMjM0NQ==). Ids piped fromskills.list/
employment.list/education.list/certifications.list/
portfolio.listwere rejected client-side withVALIDATION_ERRORbefore
the wire call, so no workingconnectionIdwas reachable end-to-end. Fix
(Approach B, decode-then-fall-back-to-raw): a newdecodeRelayNodeId
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 theadd-connectionandremove-connectionCLI
help and MCP tool descriptions clarifies that*.listtools return the
encoded form. Wire-shape disposition: Schema/contract rule triggered
(touchespackages/core/src/services/profile/skills/**), but this is a
client-side validator widening — theAddProfileSkillSetConnectionInput { skillSetId, connectionId }wire shape is unchanged; Track 1
(addProfileSkillSetConnectionsnapshot capture remains the pre-existing
deferred gap from #462 / #626).
v0.1.0-rc.12
Added
profile.employment.skills.add/profile.employment.skills.remove:
per-skill additive wrappers over the full-replaceUpdateEmployment
(#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()'sbuildUpdateEmploymentInputmerge path: read the current
row viashow(), compute the merged (dedupe by id, preserve current
order) or filtered skills array, then fire oneUpdateEmployment
mutation per row. Discriminated outcome (updated | noop | preview):
addreturnsnoopwhen every supplied id is already linked (no wire
fire);removereturnsnoopwhen no supplied id matches the row AND
refuses withVALIDATION_ERRORwhen the filtered set would be empty
(Toptal server rejectsskills: [], namingprofile.employment.remove
as the row-level alternative); caller-supplied duplicates dedupe against
each other and against current state. Core:profile.employment.skills
namespace exportingadd(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 existingDRY_RUN_EMPLOYMENT_MERGE_PLACEHOLDERfor zero-wire
preview. Wire-shape disposition: Schema/contract rule triggered
(pathpackages/core/src/services/profile/employment/**) but introduces
no new GraphQL ops — both wrappers route through the existing
UpdateEmploymentmutation; Track 1 (existing snapshot) —
UpdateEmploymentsnapshot owned by
46-profile-employment-update-merge.e2e.test.ts. Validated live
(TTCTL_E2E=1) viapackages/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 → entitylink via theremoveProfileSkillSetConnection
mutation (#463). Sibling toprofile.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, noconnectionType(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-009profile-capabilityconsent domain
(profileCapabilityConsentIssued: true). Core:
profile.skills.removeConnection(token, fields, consent, options)
returns{ skillSetId, connectionsCount, connectionIds, notice }with
the just-unlinked id absent fromconnectionIds; consent gate
(CONSENT_REQUIREDfires BEFORE dry-run);USER_ERRORmapping for
success: false/errors[];UNKNOWNfor null payload;
AuthRevokedError/Cf403Errorpropagated. CLI:ttctl profile skills remove-connection --skill-set-id <id> --connection-id <id> --consent-profile-capability(no--connection-typeflag — locked at the
unit level byexpect(flags).not.toContain('--connection-type')to
prevent regression to the 3-field wire shape). MCP:
ttctl_profile_skills_remove_connectionwithdestructiveHint: true
andprofileCapabilityConsentIssued: z.literal(true). Wire-shape
disposition: Schema/contract rule triggered (new GraphQL op
removeProfileSkillSetConnectionunder
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
viaTTCTL_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-extraconnectionTypeand
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 includingconnectionType)
was Pattern-6 inferred at #462; this PR aligns the wire to the captured
shape and keeps--connection-typeat the CLI / MCP surface as a
client-side UX guard. New privateinferConnectionTypeFromIdhelper +
RELAY_PREFIX_TO_CONNECTION_TYPEmap cross-check the declared
connectionTypeagainst theconnectionIdRelay prefix; both an
unrecognized prefix and a prefix-vs-declared-type mismatch now throw
VALIDATION_ERRORBEFORE any wire call. Wire-shape disposition:
Schema/contract rule triggered (existing op, wire input shape
modified; touchespackages/core/src/services/profile/**); Track 1
(addProfileSkillSetConnectioninTALENT_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.toEqualon the dry-run preview'svariables.input).
BC: callers passing an unrecognized Relay prefix or a
prefix-vs-declared-type mismatch now surfaceVALIDATION_ERRORat the
service boundary instead of aGRAPHQL_ERRORfrom 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, the78-profile-skills-add-connection.e2e.test.ts
header, and theaddProfileSkillSetConnectionrow rationale in
docs/wire-validation-routing.md.
Fixed
profile.specializations.showexposesoperations.apply.callableas
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 asbooleanand 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 viatypeof apply["callable"]returning"string"
not"boolean". Scope:SpecializationApplyOperation.callable
retypedstring; 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 shownow emits a string value (e.g.
"ENABLED") instead of a boolean —jq/yqconsumers comparing
with==or===need to compare against the string. Same posture
for callers ofprofile.specializations.show()programmatically.profile.education.updateno longer nulls omitted writable fields under
the full-replacement contract, andadd/updatemap ttctl-surface
institutionto wiretitle(the only school-name slot on
EducationInput) (#612). Same posture asUpdateCertification#605
andUpdateBasicInfo#604:UpdateEducationtreatsEducationInputas
a full replacement — pre-fix, callingttctl profile education update <id> --highlight truewould null every other field server-side. Pre-fix,
add()andupdate()also senteducation.institution: <value>to a
wire input that has noinstitutionslot (capture
research/captures/web/inputs/UpdateEducationInput.json) — the live API
rejected withGRAPHQL_ERROR, blocking BOTH adding and updating
education rows from ttctl. Scope:coreupdate()now reads the
current row viashow()then merges through the exported
buildUpdateEducationInput(current, fields)helper (mirror of
buildUpdateCertificationInput);add()builds the wire input via
toEducationWireInput(fields)and defaultsskills: [](the wire
requires non-null, same.blank?posture as the cert sibling).
DRY_RUN_EDUCATION_FIELD_PLACEHOLDERis exported for the MCP layer;
the MCP_updatedry-run preview now surfaces the placeholder for
every unconditional-echo field. BC: removed the--titleflag from
ttctl profile education add/updateand thetitleinput on the
MCPttctl_profile_education_add/_updatetools — the wiretitle
slot is owned byinstitution(school name), and the read-side
Education.titleis server-populated, not user-controlled. Any caller
passing--titlewas previously overwriting the school name; no
preservation path exists because the previous semantics were
data-corrupting.profile.skillsexperienceis years on the wire, not months (#627).
The MCPttctl_profile_skills_update.experiencedocstring claimed
"months"; the CLI--experienceflag,parseExperienceparser,
validation messages, and pretty-print rendering all carried the same
wrong-unit assumption (withparseExperienceACTIVELY multiplyingNy
inputs by 12). The wire capture at
research/captures/web/inputs/UpdateProfileSkillSetExperienceInput.json
annotatesInt (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...